partforge 0.123.0 → 0.124.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 +3 -2
- package/docs/AUTHORING-PARTS.md +57 -31
- package/package.json +1 -1
- package/src/framework/annotate/annotate-mode.js +6 -1
- package/src/framework/app.css +69 -57
- package/src/framework/chrome.css +38 -25
- package/src/framework/cutaway-gizmo.js +2 -1
- package/src/framework/materials/assets/SOURCES.md +1 -1
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/environment.js +16 -4
- package/src/framework/materials/patterns.js +58 -7
- package/src/framework/materials/physical.js +1 -0
- package/src/framework/materials/presets.js +6 -3
- package/src/framework/materials/print-bed.js +17 -0
- package/src/framework/mount.js +83 -36
- package/src/framework/projection.js +48 -1
- package/src/framework/view-state.js +4 -10
- package/src/framework/view-style-controls.js +287 -0
- package/src/framework/view-style-state.js +27 -0
- package/src/framework/viewcube/viewcube-controls.js +23 -64
- package/src/framework/viewcube/viewcube-mode.js +5 -3
- package/src/framework/viewer.js +526 -47
- package/types/index.d.ts +14 -8
- package/src/framework/realistic-controls.js +0 -78
package/README.md
CHANGED
|
@@ -145,8 +145,9 @@ Restore is best-effort per field, so a state that no longer fits is dropped
|
|
|
145
145
|
rather than fatal. The cut plane's world pose is restored exactly; its
|
|
146
146
|
on-screen size is re-derived from the new geometry, since that is a property of
|
|
147
147
|
the part rather than of the user's choice. Omit `viewerState` on a first mount
|
|
148
|
-
— the viewer then restores its own persisted camera
|
|
149
|
-
|
|
148
|
+
— the viewer then restores its own persisted camera, as it always has, in
|
|
149
|
+
perspective (the projection is automatic — orthographic only on a view cube
|
|
150
|
+
face view — so it is not persisted).
|
|
150
151
|
|
|
151
152
|
**Park the viewer when you hide it.** A host that hides the canvas with
|
|
152
153
|
`display: none` needs nothing — the container collapses and the ResizeObserver
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2589,7 +2589,7 @@ stylesheet). `mount` looks up these element IDs:
|
|
|
2589
2589
|
| `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` and opens the resolved default (see the "Which view the viewer opens on" rule above) |
|
|
2590
2590
|
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
2591
2591
|
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
2592
|
-
| `#viewbar` with `#annotate` / `#measure` / `#cutaway` / `#reframe` / `#
|
|
2592
|
+
| `#viewbar` with `#annotate` / `#measure` / `#cutaway` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
2593
2593
|
| `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
|
|
2594
2594
|
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme`. A sibling of `#viewbar`, not a child of it: give it `class="pf-float-rail-toggle"` and it floats at the stage's top right |
|
|
2595
2595
|
|
|
@@ -2597,16 +2597,27 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
|
|
|
2597
2597
|
workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
|
|
2598
2598
|
`"occt"` for STEP — handled for you).
|
|
2599
2599
|
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2600
|
+
**The view style button needs no markup.** `mount` generates it (`#view-style`,
|
|
2601
|
+
an eye icon) into the stage's `#viewbar`, just before `#theme` behind a thin
|
|
2602
|
+
divider — the bar's "appearance" group — so a page that copies `demo.html` gets
|
|
2603
|
+
it in the bottom toolbar and it hides with the bar (Sketch mode). A stage with
|
|
2604
|
+
no `#viewbar` gets it over the view cube's bottom-right corner instead, where it
|
|
2605
|
+
hides whenever the cube does (Sketch mode, a crowded animation transport bar).
|
|
2606
|
+
Either way it replaces the old projection toggle. It opens a popover holding every control that
|
|
2607
|
+
changes *how* the part is drawn: the **style** — CAD or one of the realistic
|
|
2608
|
+
environments (see "Materials and appearance" above), each shown as a live
|
|
2609
|
+
thumbnail of the part, re-rendered on the next open after the part or theme
|
|
2610
|
+
changes. There is no projection control: the projection is **automatic** (see
|
|
2611
|
+
`runtime.projection` below). Feature
|
|
2612
|
+
lines are CAD-only and not a switch: they draw whenever the style is CAD and
|
|
2613
|
+
never in a realistic style. The old `#realistic` / `#environment` viewbar
|
|
2614
|
+
controls (`elements.chrome.realistic` / `.environment`) and the cube's own
|
|
2615
|
+
`#projection` button were retired with it (2026-09-24); a page that still
|
|
2616
|
+
carries `#realistic` / `#environment` markup just shows dead elements, so
|
|
2617
|
+
delete them. A host can still drive `runtime.renderMode` /
|
|
2618
|
+
`runtime.environment` / `runtime.projection` from its own UI (see below). The
|
|
2619
|
+
style preferences persist across reloads the same way the theme does, and are
|
|
2620
|
+
carried in `viewerState` (below), which outranks what is stored.
|
|
2610
2621
|
|
|
2611
2622
|
**`#reframe` is supported but no longer shipped.** The framework's own pages dropped
|
|
2612
2623
|
the button on 2026-08-20: clicking a face, edge or corner on the view cube reframes
|
|
@@ -2747,12 +2758,25 @@ pane's pixel size:
|
|
|
2747
2758
|
### `runtime.projection`
|
|
2748
2759
|
|
|
2749
2760
|
`{ get(), set(mode), onChange(cb) }` where `mode` is `"perspective"` or
|
|
2750
|
-
`"orthographic"`.
|
|
2761
|
+
`"orthographic"`. The projection is **automatic**, the way Fusion 360's
|
|
2762
|
+
"Perspective with Ortho Faces" and Blender's "Auto Perspective" work: clicking
|
|
2763
|
+
one of the view cube's six **face** views (on the cube, or through its hidden
|
|
2764
|
+
per-face keyboard buttons) tweens there and settles into orthographic at the
|
|
2765
|
+
end of the tween, with no size jump; an edge, corner or iso view is perspective
|
|
2766
|
+
(an orthographic view switches back as that tween starts). In a face view,
|
|
2767
|
+
**pan and zoom keep it orthographic; the first rotation** (the view direction
|
|
2768
|
+
leaving the face axis by more than half a degree) swaps back to perspective,
|
|
2769
|
+
keeping the part's apparent size. Animation camera cues never switch into
|
|
2770
|
+
orthographic. There is no user control for it. `set("orthographic")` still
|
|
2771
|
+
works for a host, and is left the same way — by the first rotation — and
|
|
2772
|
+
`onChange` hears every automatic swap. It is **not persisted** across reloads
|
|
2773
|
+
(a reload opens in perspective); a remount carries it in `viewerState`, but
|
|
2774
|
+
only with a face-view camera — carried with any other camera it restores
|
|
2775
|
+
perspective. Drives the **live view** and `captureCurrent` only —
|
|
2751
2776
|
`captureCanonicalViews`, `renderMeshPayloads`, and the CLI's `partforge render`
|
|
2752
|
-
stay perspective unconditionally, so agent-facing output does not depend on
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
button are hidden while Sketch (annotate) mode is active, but that only governs
|
|
2777
|
+
stay perspective unconditionally, so agent-facing output does not depend on the
|
|
2778
|
+
live view. The orientation cube and the view style
|
|
2779
|
+
button (in `#viewbar`, which Sketch hides) are hidden while Sketch (annotate) mode is active, but that only governs
|
|
2756
2780
|
*user-driven* view changes — the framework does not police programmatic ones.
|
|
2757
2781
|
The ink is a transparent overlay and the WebGL canvas keeps rendering beneath
|
|
2758
2782
|
it, so a host that calls `runtime.projection.set()` mid-sketch **visibly
|
|
@@ -2765,7 +2789,7 @@ Deliberately unguarded, the same way it's always been free to call
|
|
|
2765
2789
|
### `runtime.renderMode`, `runtime.environment`, `runtime.renderViews`, `runtime.declaresMaterials`
|
|
2766
2790
|
|
|
2767
2791
|
For an embedder driving realistic mode from its own UI instead of (or in
|
|
2768
|
-
addition to) the
|
|
2792
|
+
addition to) the generated view style button above:
|
|
2769
2793
|
|
|
2770
2794
|
- `runtime.renderMode` — `{ get(), set(mode), onChange(cb) }` where `mode` is
|
|
2771
2795
|
`"cad"` or `"realistic"`. `set()` resolves to the mode actually in effect —
|
|
@@ -2792,7 +2816,10 @@ addition to) the `#realistic` / `#environment` viewbar controls above:
|
|
|
2792
2816
|
- `runtime.declaresMaterials` — `true` when any sub-part names a
|
|
2793
2817
|
`display.material`. A part with none still supports realistic mode (every
|
|
2794
2818
|
sub-part just renders under the library's `default` look), so use this to
|
|
2795
|
-
decide whether to surface
|
|
2819
|
+
decide whether to surface your own realistic control at all, not whether it works.
|
|
2820
|
+
Feature lines are not a preference: they draw whenever `runtime.renderMode`
|
|
2821
|
+
reads `"cad"` and never while it reads `"realistic"` — there is no switch
|
|
2822
|
+
to drive independently of it.
|
|
2796
2823
|
- `await runtime.renderViews(viewNames, { renderMode? })` — the appearance-aware
|
|
2797
2824
|
sibling of `runtime.captureViews` (canonical angles, framed to the visible
|
|
2798
2825
|
assembly, grid hidden): `{ renderMode: "cad" }` (the default) is exactly
|
|
@@ -2800,19 +2827,18 @@ addition to) the `#realistic` / `#environment` viewbar controls above:
|
|
|
2800
2827
|
for the capture — waiting on the chosen environment's assets — **without**
|
|
2801
2828
|
switching the live view. Rejects if the realistic assets fail to load.
|
|
2802
2829
|
|
|
2803
|
-
Both preferences round-trip through `mount()`'s `viewerState`: a
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
`
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
camera and projection.
|
|
2830
|
+
Both preferences round-trip through `mount()`'s `viewerState`: a previous
|
|
2831
|
+
mount's `runtime.getViewerState()` carries `viewerState.renderMode` (`"cad"`
|
|
2832
|
+
or `"realistic"`) and, only when the viewer's environment was actually CHOSEN
|
|
2833
|
+
rather than merely defaulted from `meta.environment`, `viewerState.environment`.
|
|
2834
|
+
`renderMode` reports the mode the user is **headed for**, not only the one on
|
|
2835
|
+
screen: a realistic restore or switch that's still loading reports
|
|
2836
|
+
`"realistic"`, so a host that remounts on every edit (as an embedder applying
|
|
2837
|
+
edits by remounting typically does) doesn't drop the in-flight choice — a
|
|
2838
|
+
load that ultimately fails still settles back to `"cad"`. Pass `viewerState`
|
|
2839
|
+
back into the next `mount()` call to resume both where the previous mount
|
|
2840
|
+
left them; omit it on a first mount and the viewer restores its own persisted
|
|
2841
|
+
choice instead, the same way it does for the camera.
|
|
2816
2842
|
|
|
2817
2843
|
### The annotation payload's camera block
|
|
2818
2844
|
|
package/package.json
CHANGED
|
@@ -515,8 +515,13 @@ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCa
|
|
|
515
515
|
}
|
|
516
516
|
|
|
517
517
|
function cameraBlock(inv) {
|
|
518
|
-
|
|
518
|
+
// The target from getCameraState, the position from the LIVE camera: under
|
|
519
|
+
// ortho getCameraState reports a perspective-equivalent position (for
|
|
520
|
+
// restoring a framing), while this block describes the camera that
|
|
521
|
+
// actually drew the frame.
|
|
522
|
+
const { target } = viewer.getCameraState();
|
|
519
523
|
const cam = viewer.camera;
|
|
524
|
+
const pos = cam.position.toArray();
|
|
520
525
|
const ortho = !!cam.isOrthographicCamera;
|
|
521
526
|
// 4 decimals ≈ 0.1 µm at mm scale — far below anything a sketch encodes,
|
|
522
527
|
// and it keeps float dust (1e-16 up-vector components) out of what an LLM
|
package/src/framework/app.css
CHANGED
|
@@ -476,8 +476,8 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
476
476
|
/* keyboard focus ring shared across the panel's interactive controls */
|
|
477
477
|
.seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
|
|
478
478
|
button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible,
|
|
479
|
-
|
|
480
|
-
.pf-
|
|
479
|
+
.pf-view-select:focus-visible,
|
|
480
|
+
.pf-float-rail-toggle:focus-visible, .pf-sketch-toolbar button:focus-visible {
|
|
481
481
|
outline: none;
|
|
482
482
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
483
483
|
}
|
|
@@ -595,21 +595,6 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
595
595
|
#viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
|
|
596
596
|
#viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
597
597
|
#viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
|
|
598
|
-
/* Realistic view (realistic-controls.js). The toggle is an ordinary viewbar
|
|
599
|
-
button that pulses while its environment loads; the environment picker is a
|
|
600
|
-
native <select> — the accessible choice on a phone — dressed as a viewbar
|
|
601
|
-
button, shown only while realistic. Keyed on `#viewbar select` rather than
|
|
602
|
-
an id so a host's own element in the pill gets the same look. */
|
|
603
|
-
#viewbar button[data-busy] { animation: pf-busy-pulse .8s ease-in-out infinite alternate; }
|
|
604
|
-
@keyframes pf-busy-pulse { from { opacity: 1; } to { opacity: .45; } }
|
|
605
|
-
@media (prefers-reduced-motion: reduce) { #viewbar button[data-busy] { animation: none; opacity: .6; } }
|
|
606
|
-
#viewbar select {
|
|
607
|
-
height: 34px; padding: 0 8px; border: 0; border-radius: var(--pf-radius-control);
|
|
608
|
-
background: transparent; color: var(--pf-muted-2); cursor: pointer;
|
|
609
|
-
font: inherit; font-size: 13px;
|
|
610
|
-
}
|
|
611
|
-
#viewbar select:hover { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
612
|
-
#viewbar select[hidden] { display: none; }
|
|
613
598
|
|
|
614
599
|
/* Sketch-mode toolbar: the #viewbar pill idiom, top-centre, owning the mode
|
|
615
600
|
while the viewbar itself is hidden (mount toggles both).
|
|
@@ -733,57 +718,84 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
733
718
|
APPEARANCE only (placement lives in chrome.css, per the split at the top of
|
|
734
719
|
this file).
|
|
735
720
|
|
|
721
|
+
The VIEW STYLE button replaced the cube's projection toggle
|
|
722
|
+
(view-style-controls.js, 2026-09-24); it now lives in #viewbar, and only a
|
|
723
|
+
host without one gets it over the cube's corner (its appearance is in the
|
|
724
|
+
next section, its placement in chrome.css). The toggle's history there ran:
|
|
725
|
+
|
|
736
726
|
Through 2026-08-19 the projection toggle sat in its own `.pf-viewcube-pill`
|
|
737
727
|
card below the cube, deliberately borrowing #viewbar's chrome so it read as
|
|
738
728
|
one of the viewer controls. The 2026-08-20 revision moved it beside the
|
|
739
729
|
cube instead, at a size and a bottom-aligned baseline that a #viewbar-style
|
|
740
|
-
card could not shrink to — so it
|
|
741
|
-
right (`.pf-viewcube-toggle`), no card, no border, no shadow.
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
that the button sits ON the cube's drawing, a persistent accent-tinted
|
|
751
|
-
disc in orthographic mode read as a blob over the geometry rather than as
|
|
752
|
-
a control. A background appears on HOVER only, in both states.
|
|
753
|
-
|
|
754
|
-
The last revision of the day took the accent COLOUR away too, so the
|
|
755
|
-
orthographic state now carries exactly ONE visual signal: the icon GLYPH (a
|
|
756
|
-
perspective frustum becomes an axonometric box). The button is grey in both
|
|
757
|
-
projections, resting and hovered, and there is deliberately no `.on` rule
|
|
758
|
-
below — viewcube-controls.js still adds the class, and `aria-pressed` still
|
|
759
|
-
carries the state for assistive tech. That is a choice, not an oversight: a
|
|
760
|
-
blue icon sitting on the cube's own drawing read as a stray tint rather than
|
|
761
|
-
as a mode, and the glyph is the tell that survives any colour scheme anyway.
|
|
762
|
-
Do not reintroduce a tint without asking. */
|
|
730
|
+
card could not shrink to — so it became a small bare 24px circle in its own
|
|
731
|
+
right (`.pf-viewcube-toggle`), no card, no border, no shadow. A same-day
|
|
732
|
+
follow-up moved it OVER the cube's bottom-right corner, took its resting
|
|
733
|
+
fill away (a tinted disc on the drawing read as a blob, not a control) and
|
|
734
|
+
then its accent colour, leaving the glyph as the one signal of the
|
|
735
|
+
projection. The view style button went the other way on purpose: the
|
|
736
|
+
request was for a control that is clearly SEEN — first #viewbar's card
|
|
737
|
+
chrome over the corner, now a button IN #viewbar. The projection itself has
|
|
738
|
+
no control at all since 2026-09-24: it is automatic (orthographic on a view
|
|
739
|
+
cube face view, perspective otherwise — viewer.js's tweenCameraTo). */
|
|
763
740
|
.pf-viewcube-canvas {
|
|
764
741
|
display: block;
|
|
765
742
|
touch-action: none;
|
|
766
743
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
744
|
+
|
|
745
|
+
/* ---- view style button + popover (view-style-controls.js) -------------------
|
|
746
|
+
In #viewbar the button is a plain `#viewbar button` (34px, transparent,
|
|
747
|
+
the bar's hover / `.on` / focus) and needs nothing here beyond the
|
|
748
|
+
divider that opens the appearance group. The card chrome below is the
|
|
749
|
+
FALLBACK's alone — the button over the cube's corner, for a host with no
|
|
750
|
+
#viewbar — where it has to read as a control on top of the drawing: 30px,
|
|
751
|
+
the viewbar's own compact button size (at 34px it covered a third of the
|
|
752
|
+
101px narrow cube). Scoped to the stack so none of it (the float shadow in
|
|
753
|
+
particular, which `#viewbar button` does not reset) leaks into the bar. */
|
|
754
|
+
.pf-viewbar-divider {
|
|
755
|
+
width: 1px; align-self: stretch; margin: 6px 2px; flex: none;
|
|
756
|
+
background: var(--pf-border);
|
|
757
|
+
}
|
|
758
|
+
.pf-viewbar-divider[hidden] { display: none; }
|
|
759
|
+
.pf-viewcube-stack > .pf-view-style-button {
|
|
760
|
+
width: 30px; height: 30px; border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control);
|
|
761
|
+
background: var(--pf-surface); box-shadow: var(--pf-shadow-float);
|
|
775
762
|
color: var(--pf-muted-2); cursor: pointer;
|
|
776
|
-
font-size: 14px; line-height: 1;
|
|
777
763
|
display: flex; align-items: center; justify-content: center;
|
|
778
764
|
}
|
|
779
|
-
.pf-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
765
|
+
.pf-view-style-button[hidden] { display: none; }
|
|
766
|
+
.pf-viewcube-stack > .pf-view-style-button:hover, .pf-viewcube-stack > .pf-view-style-button.on { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
767
|
+
.pf-view-style-popover {
|
|
768
|
+
position: absolute; z-index: 30;
|
|
769
|
+
width: min(300px, calc(100% - 24px)); overflow: auto;
|
|
770
|
+
padding: 12px; display: flex; flex-direction: column; gap: 12px;
|
|
771
|
+
background: var(--pf-surface); border: 1px solid var(--pf-border);
|
|
772
|
+
border-radius: 12px; box-shadow: var(--pf-shadow-float);
|
|
773
|
+
color: var(--pf-text); font-size: 12px;
|
|
774
|
+
}
|
|
775
|
+
.pf-view-style-popover[hidden] { display: none; }
|
|
776
|
+
.pf-view-style-heading { margin: 0; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--pf-muted-2); }
|
|
777
|
+
.pf-view-style-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
778
|
+
.pf-view-style-tile {
|
|
779
|
+
display: flex; flex-direction: column; gap: 4px; padding: 0; border: 0;
|
|
780
|
+
background: none; color: var(--pf-muted-2); cursor: pointer; font: inherit; text-align: center;
|
|
781
|
+
}
|
|
782
|
+
.pf-view-style-thumb {
|
|
783
|
+
display: block; aspect-ratio: 1; border-radius: 8px; overflow: hidden;
|
|
784
|
+
background: var(--pf-surface-2); border: 1px solid var(--pf-border);
|
|
785
|
+
}
|
|
786
|
+
.pf-view-style-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
787
|
+
.pf-view-style-thumb img[hidden] { display: none; }
|
|
788
|
+
.pf-view-style-tile[aria-pressed="true"] { color: var(--pf-text); }
|
|
789
|
+
.pf-view-style-tile[aria-pressed="true"] .pf-view-style-thumb { border-color: var(--pf-accent); box-shadow: 0 0 0 1px var(--pf-accent); }
|
|
790
|
+
.pf-view-style-tile[data-busy] .pf-view-style-thumb { opacity: 0.6; }
|
|
791
|
+
/* A tile whose environment is still loading pulses (the retired viewbar ✦
|
|
792
|
+
toggle's signal, moved onto the thing that was clicked). */
|
|
793
|
+
.pf-view-style-tile[data-busy] .pf-view-style-thumb { animation: pf-busy-pulse .8s ease-in-out infinite alternate; }
|
|
794
|
+
@keyframes pf-busy-pulse { from { opacity: 1; } to { opacity: .45; } }
|
|
795
|
+
@media (prefers-reduced-motion: reduce) { .pf-view-style-tile[data-busy] .pf-view-style-thumb { animation: none; } }
|
|
796
|
+
.pf-view-style-button:focus-visible, .pf-view-style-tile:focus-visible {
|
|
797
|
+
outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
798
|
+
}
|
|
787
799
|
|
|
788
800
|
/* At very narrow widths the full-size viewbar (5 icon buttons + cutaway's
|
|
789
801
|
Flip/Reset, ~314px) is wider than a small phone's viewport minus the
|
package/src/framework/chrome.css
CHANGED
|
@@ -249,24 +249,36 @@
|
|
|
249
249
|
56px fallback is the standard viewbar's 12px bottom + 44px height, so the
|
|
250
250
|
stack still sits right if the observer never fires.
|
|
251
251
|
|
|
252
|
-
The
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
252
|
+
The VIEW STYLE button (view-style-controls.js) now lives in #viewbar, the
|
|
253
|
+
bottom toolbar, in its APPEARANCE group beside #theme (2026-09-24, at the
|
|
254
|
+
user's request after a live mock, following Fusion 360's
|
|
255
|
+
display-settings-in-the-nav-bar convention) — the cube's corner is clear
|
|
256
|
+
again. Only a host with NO #viewbar still gets it here, over the cube's
|
|
257
|
+
bottom-right corner (the `.pf-viewcube-stack > .pf-view-style-button`
|
|
258
|
+
rule below is scoped to exactly that fallback). Its history: the
|
|
259
|
+
PROJECTION TOGGLE it replaced had moved three times (through 2026-08-19 in
|
|
260
|
+
its own `.pf-viewcube-pill` card BELOW the cube, the stack a column;
|
|
261
|
+
earlier on 2026-08-20 a bare circle BESIDE the cube, the stack a
|
|
262
|
+
`row-reverse` flex row 167px wide at the full cube; later that day OVER
|
|
263
|
+
the cube's bottom-right corner). The view style button took that corner;
|
|
264
|
+
for a few hours on 2026-09-24 it moved out beside the cube, to its left
|
|
265
|
+
(at 34px and card-chromed it covered a third of the 101px narrow cube, and
|
|
266
|
+
beside the cube it had to dodge the animation transport bar); later that
|
|
267
|
+
day it came back over the corner ("to the bottom right of the direction
|
|
268
|
+
widget"), at 30px; and later still it moved into the bottom toolbar.
|
|
269
|
+
|
|
270
|
+
In the fallback it sits inside the stack's box, so it cannot change the
|
|
271
|
+
stack's measured size — the size viewcube-controls.js publishes as
|
|
272
|
+
data-pf-w/h and the transport bar's crowding rule reads is still the
|
|
273
|
+
canvas alone (135px, or 101 below the narrow breakpoint). It is a later
|
|
274
|
+
sibling than the cube's wrapper, so it paints over the canvas with no
|
|
275
|
+
z-index of its own.
|
|
276
|
+
|
|
277
|
+
The stack keeps its right edge on the viewbar's (`.pf-float-viewbar` above
|
|
278
|
+
is `right: 12px` too), so the cube still lines up with the toolbar by
|
|
279
|
+
construction. The popover is NOT in the stack: it is appended to the stage
|
|
280
|
+
and placed at open time (above the toolbar pill, or above the fallback
|
|
281
|
+
button).
|
|
270
282
|
|
|
271
283
|
The 8px that used to sit between the stack and the viewbar is now 3px
|
|
272
284
|
(2026-08-20: "lower the whole box so it's closer to the bottom bar"). 3
|
|
@@ -283,9 +295,9 @@
|
|
|
283
295
|
this pair (3 + 5) is the whole slack that exists without either overlapping
|
|
284
296
|
the viewbar or shrinking the cube.
|
|
285
297
|
|
|
286
|
-
The stack itself is pointer-transparent.
|
|
287
|
-
|
|
288
|
-
|
|
298
|
+
The stack itself is pointer-transparent. Its box IS the canvas's box, so
|
|
299
|
+
there is no dead gap in it; the canvas (and a fallback view style
|
|
300
|
+
button) opt back in below. The
|
|
289
301
|
declaration stays because the stack's own box is still an element over the
|
|
290
302
|
viewer, and a stray hit on it (its padding-free edges, or a future child
|
|
291
303
|
before it opts in) should reach the model behind rather than be swallowed. */
|
|
@@ -298,10 +310,11 @@
|
|
|
298
310
|
pointer-events: none;
|
|
299
311
|
}
|
|
300
312
|
.pf-viewcube-stack[hidden] { display: none; }
|
|
301
|
-
.pf-viewcube-canvas, .pf-viewcube-
|
|
302
|
-
/*
|
|
303
|
-
itself absolute and so already the
|
|
304
|
-
.
|
|
313
|
+
.pf-viewcube-canvas, .pf-viewcube-stack > .pf-view-style-button { pointer-events: auto; }
|
|
314
|
+
/* The fallback only (no #viewbar): over the cube's bottom-right corner.
|
|
315
|
+
Absolute against the stack, which is itself absolute and so already the
|
|
316
|
+
containing block. In #viewbar the button is an ordinary flex item. */
|
|
317
|
+
.pf-viewcube-stack > .pf-view-style-button { position: absolute; right: 0; bottom: 0; }
|
|
305
318
|
|
|
306
319
|
/* The keyboard surface: six per-view buttons standing in for the DOM focus a
|
|
307
320
|
canvas cannot give us. Visually hidden rather than display:none — the latter
|
|
@@ -53,7 +53,8 @@ export function createCutawayGizmo({
|
|
|
53
53
|
onDragChange = () => {},
|
|
54
54
|
pickHandle,
|
|
55
55
|
}) {
|
|
56
|
-
// Reassignable: the viewer swaps cameras when the projection
|
|
56
|
+
// Reassignable: the viewer swaps cameras when the projection changes (a view
|
|
57
|
+
// cube face view settling into ortho, a rotation leaving it),
|
|
57
58
|
// and this module holds fifteen references to it. One binding to move beats
|
|
58
59
|
// threading a getter through all of them.
|
|
59
60
|
let camera = initialCamera;
|
|
@@ -68,7 +68,7 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
|
|
|
68
68
|
| `pattern-walnut-rough.jpg` | Poly Haven "Dark Wood" (roughness; API key `Rough`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_rough_1k.jpg | 2026-09-24 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture dark_wood_rough_1k.jpg pattern-walnut-rough.jpg --gray` (source is neutral grey; channel means unchanged). |
|
|
69
69
|
| `ground-maple-color.jpg` | Poly Haven "Oak Veneer 01" (colour; API key `Diffuse`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_diff_1k.jpg | 2026-09-24 | Workshop floor, "unfinished maple": `node scripts/bake-environments.mjs --texture oak_veneer_01_diff_1k.jpg ground-maple-color.jpg --modulate 1.5,0.7 --quality 85` (lightened 1.5x, saturation 0.7). |
|
|
70
70
|
| `ground-maple-normal.jpg` | Poly Haven "Oak Veneer 01" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_nor_gl_1k.jpg | 2026-09-24 | Workshop floor relief: `node scripts/bake-environments.mjs --texture oak_veneer_01_nor_gl_1k.jpg ground-maple-normal.jpg --data --quality 92`. A separate file from `pattern-oak-normal.jpg` so the floor and the oak material never share (and dispose) one texture. |
|
|
71
|
-
| `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `
|
|
71
|
+
| `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `168,134,82` bronze-gold base (asymmetric per channel so flecks warm rather than grey), then a 0.5px blur to turn single-pixel noise into soft grains -> `node scripts/bake-environments.mjs --speckle ground-pei.jpg --tint 168,134,82`. (Re-baked 2026-09-24 from `196,154,82`: darker and less saturated, closer to a Bambu textured-PEI plate's muted bronze-gold.) |
|
|
72
72
|
| `ground-pei-rough.jpg` | ambientCG "Plastic 010" (Roughness) | https://ambientcg.com/a/Plastic010 | 2026-09-24 | 1K JPG, grayscale -> `node scripts/bake-environments.mjs --texture Plastic010_1K-JPG_Roughness.jpg ground-pei-rough.jpg --gray` |
|
|
73
73
|
| `ground-concrete.jpg` | ambientCG "Concrete 047 A" (Color) | https://ambientcg.com/a/Concrete047A | 2026-09-24 | 2K JPG -> `node scripts/bake-environments.mjs --texture Concrete047A_2K-JPG_Color.jpg ground-concrete.jpg --size 1024 --modulate 1.35,0 --flatten 0.7 --quality 82` (grey, brightened to the old map's mean so the concrete material preset keeps its tone; `--flatten 0.7` is 30% less grain contrast). Replaced ambientCG "Concrete 034", whose colour map was nearly flat (stdev ~5/255) and read as blur up close; 047 A's exposed aggregate keeps fine grain. |
|
|
74
74
|
| `ground-concrete-rough.jpg` | ambientCG "Concrete 047 A" (Roughness) | https://ambientcg.com/a/Concrete047A | 2026-09-24 | 2K JPG, grayscale -> `node scripts/bake-environments.mjs --texture Concrete047A_2K-JPG_Roughness.jpg ground-concrete-rough.jpg --gray --size 1024` |
|
|
Binary file
|
|
@@ -94,21 +94,32 @@ export async function loadEnvironmentRig(renderer, requestedId, { loadHdr, loadT
|
|
|
94
94
|
if (env.ground.bed) {
|
|
95
95
|
const material = groundMaterial(tex, rough, normal, { ...env.ground, bed: true });
|
|
96
96
|
const bed = createPrintBed({ pei: material, tileMm: env.ground.tileMm ?? env.ground.sizeMm, createCanvas });
|
|
97
|
-
// An enclosure's LED bar: one hard overhead light,
|
|
98
|
-
//
|
|
97
|
+
// An enclosure's LED bar: one hard overhead light, aimed at the bed's own
|
|
98
|
+
// origin. The LIGHT itself is deliberately not a child of bed.object:
|
|
99
|
+
// updateForCamera hides that whole group from below, and a hidden parent
|
|
100
|
+
// stops three from rendering its children too — the light would go dark
|
|
101
|
+
// exactly when the part needs it lit from underneath. It rides in
|
|
102
|
+
// `lights` instead and is repositioned by hand wherever the bed moves.
|
|
103
|
+
// The target CAN stay a child (matrixWorld updates regardless of
|
|
104
|
+
// visibility), which is what lets it keep tracking the bed for free.
|
|
99
105
|
let keyLight = null;
|
|
100
106
|
if (env.keyLight) {
|
|
101
107
|
keyLight = new THREE.DirectionalLight(0xffffff, env.keyLight.intensity);
|
|
102
|
-
|
|
103
|
-
bed.object.add(keyLight, keyLight.target);
|
|
108
|
+
bed.object.add(keyLight.target);
|
|
104
109
|
}
|
|
105
110
|
return {
|
|
106
111
|
id, exposure: env.exposure, envMap, background, backgroundBlurriness, backgroundIntensity,
|
|
107
112
|
rotationY: ((env.rotationDeg ?? 0) * Math.PI) / 180,
|
|
108
113
|
ground: bed.object,
|
|
114
|
+
lights: keyLight ? [keyLight] : [],
|
|
109
115
|
shadow,
|
|
116
|
+
updateForCamera(camera) { bed.updateForCamera(camera); },
|
|
110
117
|
setGround({ y, centerX = 0, centerZ = 0, radius, footprintMm = radius }) {
|
|
111
118
|
bed.place({ y, centerX, centerZ, footprintMm });
|
|
119
|
+
if (keyLight) {
|
|
120
|
+
const p = bed.object.position; // the fixed offset off the bed's origin
|
|
121
|
+
keyLight.position.set(p.x + env.keyLight.direction[0], p.y + env.keyLight.direction[1], p.z + env.keyLight.direction[2]);
|
|
122
|
+
}
|
|
112
123
|
shadow.group.position.set(centerX, y, centerZ);
|
|
113
124
|
// The shadow plane never hangs past the plate's edge into thin air.
|
|
114
125
|
const s = Math.min(Math.max(radius * 3, 20), bed.sizeMm);
|
|
@@ -147,6 +158,7 @@ export async function loadEnvironmentRig(renderer, requestedId, { loadHdr, loadT
|
|
|
147
158
|
backgroundIntensity,
|
|
148
159
|
rotationY: ((env.rotationDeg ?? 0) * Math.PI) / 180,
|
|
149
160
|
ground,
|
|
161
|
+
lights: [],
|
|
150
162
|
shadow,
|
|
151
163
|
setGround,
|
|
152
164
|
dispose() {
|
|
@@ -45,10 +45,22 @@ uniform sampler2D pfNormalMap;
|
|
|
45
45
|
uniform sampler2D pfRoughMap;
|
|
46
46
|
uniform float pfRoughMean;
|
|
47
47
|
uniform float pfNormalStrength;
|
|
48
|
+
uniform vec3 pfGrainSwap;
|
|
48
49
|
varying vec3 vPfNmX;
|
|
49
50
|
varying vec3 vPfNmY;
|
|
50
51
|
varying vec3 vPfNmZ;
|
|
51
52
|
vec3 pfTriplanarWeights(vec3 n) { vec3 w = pow(abs(n), vec3(4.0)); return w / (w.x + w.y + w.z); }
|
|
53
|
+
// One grain direction on every face: each projection's UV pair is transposed
|
|
54
|
+
// where pfGrainSwap says so (grainSwaps below), so the texture's grain lands on
|
|
55
|
+
// the sub-part's grain axis. A transpose is a mirror, not a rotation, which is
|
|
56
|
+
// what keeps the normal map exact: its slopes just swap axes (t.yx), no signs.
|
|
57
|
+
vec2 pfGrainUv(vec2 ab, float s) { return mix(ab, ab.yx, s); }
|
|
58
|
+
vec3 pfTriplanarWood(sampler2D map, vec3 p, vec3 n) {
|
|
59
|
+
vec3 w = pfTriplanarWeights(n);
|
|
60
|
+
return texture2D(map, pfGrainUv(p.yz, pfGrainSwap.x) / pfPatternScale).rgb * w.x
|
|
61
|
+
+ texture2D(map, pfGrainUv(p.xz, pfGrainSwap.y) / pfPatternScale).rgb * w.y
|
|
62
|
+
+ texture2D(map, pfGrainUv(p.xy, pfGrainSwap.z) / pfPatternScale).rgb * w.z;
|
|
63
|
+
}
|
|
52
64
|
// Triplanar tangent-space normal map, whiteout-blended (Golus, "Normal Mapping
|
|
53
65
|
// for a Triplanar Shader"). Each projection's map is read as a height field over
|
|
54
66
|
// the two object axes its UVs run along, so its tangent normal (x, y) is a slope
|
|
@@ -59,10 +71,13 @@ vec3 pfTriplanarWeights(vec3 n) { vec3 w = pow(abs(n), vec3(4.0)); return w / (w
|
|
|
59
71
|
// Poly Haven "nor_gl" convention. Returns an OBJECT-space unit normal.
|
|
60
72
|
vec3 pfTriplanarNormal(vec3 p, vec3 n, float scale) {
|
|
61
73
|
vec3 w = pfTriplanarWeights(n);
|
|
62
|
-
vec3 tx = texture2D(pfNormalMap, p.yz / scale).xyz * 2.0 - 1.0;
|
|
63
|
-
vec3 ty = texture2D(pfNormalMap, p.xz / scale).xyz * 2.0 - 1.0;
|
|
64
|
-
vec3 tz = texture2D(pfNormalMap, p.xy / scale).xyz * 2.0 - 1.0;
|
|
65
|
-
|
|
74
|
+
vec3 tx = texture2D(pfNormalMap, pfGrainUv(p.yz, pfGrainSwap.x) / scale).xyz * 2.0 - 1.0;
|
|
75
|
+
vec3 ty = texture2D(pfNormalMap, pfGrainUv(p.xz, pfGrainSwap.y) / scale).xyz * 2.0 - 1.0;
|
|
76
|
+
vec3 tz = texture2D(pfNormalMap, pfGrainUv(p.xy, pfGrainSwap.z) / scale).xyz * 2.0 - 1.0;
|
|
77
|
+
// a transposed sample's slopes run along the swapped axes
|
|
78
|
+
tx.xy = pfGrainUv(tx.xy, pfGrainSwap.x) * pfNormalStrength;
|
|
79
|
+
ty.xy = pfGrainUv(ty.xy, pfGrainSwap.y) * pfNormalStrength;
|
|
80
|
+
tz.xy = pfGrainUv(tz.xy, pfGrainSwap.z) * pfNormalStrength;
|
|
66
81
|
tx = vec3(tx.xy + n.yz, abs(tx.z) * n.x); // uv = (y, z), face axis x
|
|
67
82
|
ty = vec3(ty.xy + n.xz, abs(ty.z) * n.y); // uv = (x, z), face axis y
|
|
68
83
|
tz = vec3(tz.xy + n.xy, abs(tz.z) * n.z); // uv = (x, y), face axis z
|
|
@@ -102,11 +117,12 @@ const FRAG_BODY = {
|
|
|
102
117
|
// author tinted it, physical.js — and the roughness map varies roughnessFactor
|
|
103
118
|
// around the preset's value (divided by the map's own mean, so the preset sets
|
|
104
119
|
// the average). Mipmaps (and RepeatWrapping) do the far-distance filtering.
|
|
120
|
+
// All three maps follow the grain axis (pfTriplanarWood / setGrainAxis).
|
|
105
121
|
wood: `
|
|
106
122
|
{
|
|
107
123
|
vec3 pfN = normalize(vPfObjNormal);
|
|
108
|
-
diffuseColor.rgb *=
|
|
109
|
-
float r =
|
|
124
|
+
diffuseColor.rgb *= pfTriplanarWood(pfPatternMap, vPfObjPos, pfN);
|
|
125
|
+
float r = pfTriplanarWood(pfRoughMap, vPfObjPos, pfN).g;
|
|
110
126
|
roughnessFactor = clamp(roughnessFactor * r / pfRoughMean, 0.02, 1.0);
|
|
111
127
|
}`,
|
|
112
128
|
// Carbon samples a luminance MASK (raw, not sRGB-decoded: physical.js) and
|
|
@@ -164,7 +180,40 @@ const FRAG_NORMAL = {
|
|
|
164
180
|
}`,
|
|
165
181
|
};
|
|
166
182
|
|
|
167
|
-
|
|
183
|
+
// Wood grain: real wood has ONE grain direction, the board's long axis, but a
|
|
184
|
+
// triplanar projection with fixed UVs (X-faces (y, z), Y-faces (x, z), Z-faces
|
|
185
|
+
// (x, y)) lands the texture's grain on a different object axis per face — on a
|
|
186
|
+
// block, adjacent faces showed grain at right angles. So each sub-part gets a
|
|
187
|
+
// grain axis (its longest object-space bounding-box axis, X on a tie) and every
|
|
188
|
+
// projection whose face CONTAINS that axis is transposed where needed so the
|
|
189
|
+
// texture's grain runs along it. Faces across the axis are end grain; there is
|
|
190
|
+
// no end-grain texture, so they keep the default sample.
|
|
191
|
+
//
|
|
192
|
+
// `grain` is which texture axis a scan's figure runs along ("u" horizontal, "v"
|
|
193
|
+
// vertical in the image), declared per texture set in presets.js.
|
|
194
|
+
const PLANE_AXES = [[1, 2], [0, 2], [0, 1]]; // each projection's (u, v) object axes
|
|
195
|
+
export function grainAxisFor(box) {
|
|
196
|
+
if (!box || box.isEmpty?.()) return 0;
|
|
197
|
+
const s = [box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z];
|
|
198
|
+
const eps = Math.max(...s) * 1e-6;
|
|
199
|
+
let axis = 0;
|
|
200
|
+
for (let i = 1; i < 3; i++) if (s[i] > s[axis] + eps) axis = i;
|
|
201
|
+
return axis;
|
|
202
|
+
}
|
|
203
|
+
export function grainSwaps(axis, grain = "u") {
|
|
204
|
+
const want = grain === "v" ? 1 : 0;
|
|
205
|
+
return PLANE_AXES.map(([u], face) => (face === axis ? 0 : (axis === u ? 0 : 1) !== want ? 1 : 0));
|
|
206
|
+
}
|
|
207
|
+
// Point a wood material (and every clone sharing its uniforms) at a grain axis.
|
|
208
|
+
// A no-op on any other material.
|
|
209
|
+
export function setGrainAxis(material, axis) {
|
|
210
|
+
const u = material?.userData?.patternUniforms?.pfGrainSwap;
|
|
211
|
+
if (!u) return;
|
|
212
|
+
material.userData.pfGrainAxis = axis;
|
|
213
|
+
u.value.fromArray(grainSwaps(axis, material.userData.pfGrain));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function applyPattern(material, { kind, scale = 1, printFrame, texture, normalMap, roughnessMap, roughnessMean = 0.5, normalScale = 1, grain = "u" } = {}) {
|
|
168
217
|
if (!kind || !FRAG_BODY[kind]) return material;
|
|
169
218
|
const wood = kind === "wood";
|
|
170
219
|
const uniforms = {
|
|
@@ -176,9 +225,11 @@ export function applyPattern(material, { kind, scale = 1, printFrame, texture, n
|
|
|
176
225
|
pfRoughMap: { value: roughnessMap ?? null },
|
|
177
226
|
pfRoughMean: { value: roughnessMean },
|
|
178
227
|
pfNormalStrength: { value: normalScale },
|
|
228
|
+
pfGrainSwap: { value: new THREE.Vector3() },
|
|
179
229
|
} : {}),
|
|
180
230
|
};
|
|
181
231
|
material.userData.patternUniforms = uniforms;
|
|
232
|
+
if (wood) { material.userData.pfGrain = grain; setGrainAxis(material, 0); }
|
|
182
233
|
material.onBeforeCompile = (shader) => {
|
|
183
234
|
Object.assign(shader.uniforms, uniforms);
|
|
184
235
|
shader.vertexShader = shader.vertexShader
|