partforge 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/bin/cli.js +38 -11
- package/docs/AUTHORING-PARTS.md +102 -36
- package/package.json +1 -1
- package/src/framework/animation-controls.js +108 -48
- package/src/framework/animation.js +47 -18
- package/src/framework/app.css +89 -21
- package/src/framework/controls.js +1 -1
- package/src/framework/cutaway.js +14 -0
- package/src/framework/lint/rules-animations.js +177 -92
- package/src/framework/mount.js +36 -11
- package/src/framework/panel/info.js +10 -1
- package/src/framework/panel/render.js +39 -13
- package/src/framework/panel/widgets/checkbox.js +2 -1
- package/src/framework/panel/widgets/numeric.js +3 -1
- package/src/framework/panel/widgets/select.js +4 -2
- package/src/framework/panel/widgets/text.js +2 -1
- package/src/framework/viewer.js +155 -9
- package/src/parts/hinged-box.js +43 -27
- package/src/testing/render.js +43 -7
- package/types/index.d.ts +17 -7
- package/types/part.d.ts +38 -12
- package/types/testing.d.ts +8 -0
|
@@ -51,12 +51,13 @@ export function normalizeAnimation(name, spec) {
|
|
|
51
51
|
duration: s.duration,
|
|
52
52
|
easing: s.easing ?? spec.easing ?? DEFAULT_EASING,
|
|
53
53
|
tracks: s.tracks ?? {},
|
|
54
|
+
opacity: s.opacity ?? {},
|
|
54
55
|
camera: s.camera ?? null,
|
|
55
56
|
}))
|
|
56
57
|
: [{
|
|
57
58
|
label: null, duration: spec.duration,
|
|
58
59
|
easing: spec.easing ?? DEFAULT_EASING,
|
|
59
|
-
tracks: spec.tracks ?? {}, camera: null,
|
|
60
|
+
tracks: spec.tracks ?? {}, opacity: spec.opacity ?? {}, camera: null,
|
|
60
61
|
}];
|
|
61
62
|
const totalDuration = steps.reduce((sum, s) => sum + s.duration, 0) || 1;
|
|
62
63
|
let acc = 0;
|
|
@@ -65,8 +66,13 @@ export function normalizeAnimation(name, spec) {
|
|
|
65
66
|
if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera }];
|
|
66
67
|
else if (Array.isArray(spec.camera)) cues = spec.camera.map(([t, view]) => ({ t, view }));
|
|
67
68
|
else cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera }] : []));
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
// Keys with at least one usable keyframe list in any step, for one field
|
|
70
|
+
// ("tracks" or "opacity"). Shares usableKeyframes with segmentsFor — the
|
|
71
|
+
// single rule both must agree on (see the comment on usableKeyframes).
|
|
72
|
+
const keysOf = (field) => [...new Set(steps.flatMap((s) =>
|
|
73
|
+
Object.entries(s[field]).filter(([, kf]) => usableKeyframes(kf)).map(([key]) => key)))];
|
|
74
|
+
const trackedKeys = keysOf("tracks");
|
|
75
|
+
const opacityKeys = keysOf("opacity");
|
|
70
76
|
return {
|
|
71
77
|
name, label: spec.label ?? name, description: spec.description ?? null,
|
|
72
78
|
// Fail CLOSED on both flags: only a literal `true` turns them on. Coercing
|
|
@@ -76,12 +82,32 @@ export function normalizeAnimation(name, spec) {
|
|
|
76
82
|
// having been linted. An invalid flag therefore does the quiet thing here and
|
|
77
83
|
// is reported there.
|
|
78
84
|
loop: spec.loop === true, autoplay: spec.autoplay === true,
|
|
79
|
-
steps, stepStarts, totalDuration, cues, trackedKeys,
|
|
85
|
+
steps, stepStarts, totalDuration, cues, trackedKeys, opacityKeys,
|
|
80
86
|
};
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
// Per-view normalized animations (spec 2026-08-10-per-view-animations):
|
|
90
|
+
// Map(viewName -> NormalizedAnimation[]), one entry per declared view, [] for
|
|
91
|
+
// views without animations. Malformed entries are SKIPPED, not thrown — the
|
|
92
|
+
// runtime must degrade to "that animation doesn't exist" while lint reports
|
|
93
|
+
// the specifics. A legacy top-level `animations` key is deliberately ignored
|
|
94
|
+
// (clean break; lint's animation-not-in-view names the fix).
|
|
95
|
+
export function viewAnimations(part) {
|
|
96
|
+
const out = new Map();
|
|
97
|
+
const views = part?.views;
|
|
98
|
+
if (views === null || typeof views !== "object" || Array.isArray(views)) return out;
|
|
99
|
+
for (const [viewName, view] of Object.entries(views)) {
|
|
100
|
+
const block = view?.animations;
|
|
101
|
+
const entries = block !== null && typeof block === "object" && !Array.isArray(block)
|
|
102
|
+
? Object.entries(block)
|
|
103
|
+
: [];
|
|
104
|
+
const anims = [];
|
|
105
|
+
for (const [name, spec] of entries) {
|
|
106
|
+
try { anims.push(normalizeAnimation(name, spec)); } catch { /* lint reports */ }
|
|
107
|
+
}
|
|
108
|
+
out.set(viewName, anims);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
85
111
|
}
|
|
86
112
|
|
|
87
113
|
// Step containing t. Boundaries belong to the LATER step, and t clamps to [0,1].
|
|
@@ -100,11 +126,12 @@ export function cueAt(anim, t) {
|
|
|
100
126
|
return g;
|
|
101
127
|
}
|
|
102
128
|
|
|
103
|
-
// The timeline segments (global [start,end] spans) in which `key` is tracked
|
|
104
|
-
|
|
129
|
+
// The timeline segments (global [start,end] spans) in which `key` is tracked,
|
|
130
|
+
// for one keyframe field ("tracks" for params, "opacity" for sub-part fades).
|
|
131
|
+
function segmentsFor(anim, key, field = "tracks") {
|
|
105
132
|
const out = [];
|
|
106
133
|
anim.steps.forEach((step, i) => {
|
|
107
|
-
const kf = step
|
|
134
|
+
const kf = step[field][key];
|
|
108
135
|
if (!usableKeyframes(kf)) return;
|
|
109
136
|
const start = anim.stepStarts[i];
|
|
110
137
|
const end = i + 1 < anim.steps.length ? anim.stepStarts[i + 1] : 1;
|
|
@@ -126,11 +153,11 @@ function interpKeyframes(kf, u) {
|
|
|
126
153
|
return kf[kf.length - 1][1];
|
|
127
154
|
}
|
|
128
155
|
|
|
129
|
-
function evaluateTrack(anim, key, t) {
|
|
130
|
-
const segs = segmentsFor(anim, key);
|
|
131
|
-
// trackedKeys and segmentsFor share usableKeyframes, so a tracked
|
|
132
|
-
// has a segment. Guard anyway: this runs inside the render loop,
|
|
133
|
-
// costs the whole viewer, not just the frame.
|
|
156
|
+
function evaluateTrack(anim, key, t, field = "tracks") {
|
|
157
|
+
const segs = segmentsFor(anim, key, field);
|
|
158
|
+
// trackedKeys/opacityKeys and segmentsFor share usableKeyframes, so a tracked
|
|
159
|
+
// key always has a segment. Guard anyway: this runs inside the render loop,
|
|
160
|
+
// where a throw costs the whole viewer, not just the frame.
|
|
134
161
|
if (!segs.length) return undefined;
|
|
135
162
|
let prev = null;
|
|
136
163
|
for (const seg of segs) {
|
|
@@ -142,8 +169,8 @@ function evaluateTrack(anim, key, t) {
|
|
|
142
169
|
}
|
|
143
170
|
prev = seg;
|
|
144
171
|
}
|
|
145
|
-
// Outside every segment: hold the nearest boundary value, so a param
|
|
146
|
-
// only in step 2 doesn't jump while step 1 plays.
|
|
172
|
+
// Outside every segment: hold the nearest boundary value, so a param (or an
|
|
173
|
+
// opacity) tracked only in step 2 doesn't jump while step 1 plays.
|
|
147
174
|
return prev ? prev.keyframes[prev.keyframes.length - 1][1] : segs[0].keyframes[0][1];
|
|
148
175
|
}
|
|
149
176
|
|
|
@@ -152,8 +179,10 @@ function evaluateTrack(anim, key, t) {
|
|
|
152
179
|
export function evaluate(anim, t) {
|
|
153
180
|
const tc = clampT(t);
|
|
154
181
|
const values = {};
|
|
155
|
-
for (const key of anim.trackedKeys) values[key] = evaluateTrack(anim, key, tc);
|
|
156
|
-
|
|
182
|
+
for (const key of anim.trackedKeys) values[key] = evaluateTrack(anim, key, tc, "tracks");
|
|
183
|
+
const opacity = {};
|
|
184
|
+
for (const key of anim.opacityKeys) opacity[key] = evaluateTrack(anim, key, tc, "opacity");
|
|
185
|
+
return { stepIndex: stepIndexAt(anim, tc), values, opacity };
|
|
157
186
|
}
|
|
158
187
|
|
|
159
188
|
// --- playback state machine --------------------------------------------------
|
package/src/framework/app.css
CHANGED
|
@@ -53,7 +53,10 @@ canvas { display: block; }
|
|
|
53
53
|
sits at the rail's own padding, so each slider gains the ~22px the old box
|
|
54
54
|
border + padding used to take from both sides. */
|
|
55
55
|
.section {
|
|
56
|
-
padding:
|
|
56
|
+
/* No vertical padding at the top: the header band (below) owns the space
|
|
57
|
+
around the title so a section's title sits at the same offset whether
|
|
58
|
+
the section is open or collapsed — toggling must not move it. */
|
|
59
|
+
padding: 0 var(--pf-rail-pad) 11px;
|
|
57
60
|
}
|
|
58
61
|
/* Divider BETWEEN visible sections. `~` walks all preceding siblings, and a
|
|
59
62
|
relevance-hidden section (.section-hidden, display:none) fails the :not(),
|
|
@@ -65,28 +68,70 @@ canvas { display: block; }
|
|
|
65
68
|
.section:not(.section-hidden):not(.hidden) ~ .section:not(.section-hidden):not(.hidden) {
|
|
66
69
|
border-top: 1px solid var(--pf-border);
|
|
67
70
|
}
|
|
68
|
-
|
|
71
|
+
/* Section disclosure header: the whole row is the click target (render.js
|
|
72
|
+
puts the toggle listener on the header; the title button's click bubbles
|
|
73
|
+
to it and the ⓘ stops propagation). Full-bleed: negative margins cancel
|
|
74
|
+
the rail padding so the hover band and the collapsed rules run edge to
|
|
75
|
+
edge while the text keeps the rail's alignment. Row order: title (flex:1),
|
|
76
|
+
ⓘ, chevron on the far right. */
|
|
77
|
+
.sec-header {
|
|
78
|
+
display: flex; align-items: center; gap: 6px;
|
|
79
|
+
margin: 0 calc(-1 * var(--pf-rail-pad)) 6px;
|
|
80
|
+
/* The same 13px band open or collapsed: the title never moves on toggle
|
|
81
|
+
and the hover target keeps one size. */
|
|
82
|
+
padding: 13px var(--pf-rail-pad);
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
}
|
|
85
|
+
.sec-header:hover { background: var(--pf-surface-2); }
|
|
69
86
|
.sec-title {
|
|
70
|
-
flex: 1; display: flex; align-items: center;
|
|
71
|
-
|
|
87
|
+
flex: 1; display: flex; align-items: center;
|
|
88
|
+
margin: 0; padding: 0; border: 0; background: transparent; cursor: pointer;
|
|
72
89
|
text-align: left;
|
|
73
|
-
font-family: var(--pf-mono); font-size:
|
|
74
|
-
letter-spacing: 0.
|
|
75
|
-
}
|
|
76
|
-
.sec-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
font-family: var(--pf-mono); font-size: 12px; font-weight: 600;
|
|
91
|
+
letter-spacing: 0.12em; text-transform: uppercase; color: var(--pf-text-2);
|
|
92
|
+
}
|
|
93
|
+
.sec-header:hover .sec-title { color: var(--pf-text); }
|
|
94
|
+
/* Disclosure triangle: closes the header row on the right, after the ⓘ.
|
|
95
|
+
Glyph via ::before — the span must stay text-free (tests match .sec-title
|
|
96
|
+
by exact textContent). Rotation keys off the collapsed class because the
|
|
97
|
+
chevron sits beside, not inside, the aria-carrying button. */
|
|
98
|
+
/* A real equilateral triangle (clip-path, not a font glyph): the box is
|
|
99
|
+
exactly the triangle's bounds, so rotating about the center is wobble-free
|
|
100
|
+
— a font's ▾ sits off-center in its em box and lurches when rotated.
|
|
101
|
+
Height = width × √3/2 keeps it equilateral. */
|
|
102
|
+
.sec-header .chev, .adv-header .chev {
|
|
103
|
+
display: inline-block; width: 8px; height: 7px; color: var(--pf-muted-2);
|
|
104
|
+
transition: transform 0.15s ease;
|
|
105
|
+
}
|
|
106
|
+
.sec-header .chev::before, .adv-header .chev::before {
|
|
107
|
+
content: ""; display: block; width: 100%; height: 100%;
|
|
108
|
+
background: currentColor;
|
|
109
|
+
clip-path: polygon(50% 100%, 0 0, 100% 0);
|
|
110
|
+
}
|
|
111
|
+
.section.collapsed > .sec-header .chev, .adv-wrap.collapsed > .adv-header .chev { transform: rotate(-90deg); }
|
|
112
|
+
/* Collapsed section: the header band sits flush between the section's top
|
|
113
|
+
divider and its own bottom rule — text vertically centered, hover and
|
|
114
|
+
click covering the entire band. The -1px bottom margin overlaps this rule
|
|
115
|
+
with the next section's border-top so they read as one line. */
|
|
116
|
+
.section.collapsed {
|
|
117
|
+
padding-bottom: 0;
|
|
118
|
+
border-bottom: 1px solid var(--pf-border); margin-bottom: -1px;
|
|
119
|
+
}
|
|
120
|
+
.section.collapsed .sec-header { margin-bottom: 0; }
|
|
80
121
|
.sec-body.hidden { display: none; }
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
select.select-input {
|
|
122
|
+
/* Native menulist arrows hug the right border and ignore padding, so the
|
|
123
|
+
panel's selects draw their own — the header chevrons' equilateral triangle
|
|
124
|
+
as a data-URI, inset 10px from the edge. Fixed slate fill: url() can't
|
|
125
|
+
read theme variables, and this shade reads on both themes. */
|
|
126
|
+
select.preset, select.select-input {
|
|
87
127
|
width: 100%; background: var(--pf-input-bg); color: var(--pf-text-2);
|
|
88
|
-
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control);
|
|
128
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control);
|
|
129
|
+
padding: 7px 26px 7px 9px;
|
|
89
130
|
font-family: var(--pf-mono); font-size: 11px;
|
|
131
|
+
appearance: none;
|
|
132
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='7'%3E%3Cpolygon points='0,0 8,0 4,7' fill='%238b8b94'/%3E%3C/svg%3E");
|
|
133
|
+
background-repeat: no-repeat;
|
|
134
|
+
background-position: right 10px center;
|
|
90
135
|
}
|
|
91
136
|
.feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
|
|
92
137
|
color: var(--pf-text-2); cursor: pointer; }
|
|
@@ -97,12 +142,35 @@ select.select-input {
|
|
|
97
142
|
`.sec-body` (rules above); everything else carrying `.hidden` inside the
|
|
98
143
|
panel is a `when` that evaluated false. */
|
|
99
144
|
.section.hidden, .slider.hidden, .feat.hidden, select.preset.hidden { display: none; }
|
|
145
|
+
/* Inner fold header: same row anatomy as .sec-header (title left, chevron
|
|
146
|
+
right, whole row clickable) at the subordinate scale. Spans the fold's own
|
|
147
|
+
width — not full-bleed — so it never crosses a feat-group's left border;
|
|
148
|
+
small negative margins let the hover band breathe past the text without
|
|
149
|
+
moving it. */
|
|
150
|
+
.adv-header {
|
|
151
|
+
display: flex; align-items: center; gap: 6px;
|
|
152
|
+
margin: 8px -6px 0; padding: 8px 6px;
|
|
153
|
+
/* Transparent borders reserve the collapsed rules' pixels so toggling
|
|
154
|
+
swaps only their color — the title never moves and the hover target
|
|
155
|
+
keeps one size, matching the section-header treatment. */
|
|
156
|
+
border-top: 1px solid transparent; border-bottom: 1px solid transparent;
|
|
157
|
+
border-radius: 6px; cursor: pointer;
|
|
158
|
+
}
|
|
159
|
+
.adv-header:hover { background: var(--pf-surface-2); }
|
|
100
160
|
.adv-toggle {
|
|
101
|
-
|
|
161
|
+
flex: 1; margin: 0; padding: 0; border: 0;
|
|
102
162
|
background: transparent; color: var(--pf-muted); cursor: pointer;
|
|
103
|
-
font-family: var(--pf-mono); font-size:
|
|
163
|
+
font-family: var(--pf-mono); font-size: 12px; font-weight: 600;
|
|
164
|
+
letter-spacing: 0.08em; text-transform: uppercase; text-align: left;
|
|
165
|
+
}
|
|
166
|
+
.adv-header:hover .adv-toggle { color: var(--pf-muted-2); }
|
|
167
|
+
/* Collapsed fold: the reserved borders take color — rules above and below,
|
|
168
|
+
text vertically centered between them (the section-band treatment at fold
|
|
169
|
+
scale). Square corners while the rules show. */
|
|
170
|
+
.adv-wrap.collapsed > .adv-header {
|
|
171
|
+
border-top-color: var(--pf-border); border-bottom-color: var(--pf-border);
|
|
172
|
+
border-radius: 0;
|
|
104
173
|
}
|
|
105
|
-
.adv-toggle:hover { color: var(--pf-muted-2); }
|
|
106
174
|
.adv.hidden { display: none; }
|
|
107
175
|
.adv { margin-top: 4px; }
|
|
108
176
|
|
|
@@ -10,6 +10,6 @@
|
|
|
10
10
|
// This file stays at its path because animation-controls.js imports the popover
|
|
11
11
|
// helpers from it, and because it is the documented import site.
|
|
12
12
|
export { buildControls } from "./panel/render.js";
|
|
13
|
-
export { popoverTop, createInfoPopover, attachInfo } from "./panel/info.js";
|
|
13
|
+
export { popoverTop, popoverLeft, createInfoPopover, attachInfo } from "./panel/info.js";
|
|
14
14
|
export { clampToRange } from "./panel/widgets/numeric.js";
|
|
15
15
|
export { visibleAdvanced, visibleFeatures, visibleToggles, sectionRenders } from "./panel/legacy.js";
|
package/src/framework/cutaway.js
CHANGED
|
@@ -412,6 +412,19 @@ export function createCutaway({
|
|
|
412
412
|
};
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
// Re-assert this sub-part's enabled-state material assignment. The viewer
|
|
416
|
+
// calls it after taking a sub-part OFF a fade clone: while the cutaway is
|
|
417
|
+
// enabled the mesh belongs on clippedMeshMaterial, and only the render set
|
|
418
|
+
// knows that material. setEnabled is idempotent, so this is a cheap
|
|
419
|
+
// re-assert, not a rebuild.
|
|
420
|
+
function resyncSubpart(name) {
|
|
421
|
+
if (disposed) return false;
|
|
422
|
+
const entry = renderSets.get(name);
|
|
423
|
+
if (!entry) return false;
|
|
424
|
+
entry.renderSet.setEnabled(enabled);
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
415
428
|
// Per-frame maintenance while the cutaway is on: the gizmo rescales for the
|
|
416
429
|
// camera, and every visible section re-slices its outline if anything it
|
|
417
430
|
// depends on moved. Both are cheap no-ops when nothing changed.
|
|
@@ -499,6 +512,7 @@ export function createCutaway({
|
|
|
499
512
|
updateGeometry,
|
|
500
513
|
setVisible,
|
|
501
514
|
setEnabled,
|
|
515
|
+
resyncSubpart,
|
|
502
516
|
reset,
|
|
503
517
|
flip,
|
|
504
518
|
setTheme,
|