1 Commits
Author SHA1 Message Date
MichaelScottsman 80d4710f3f assPod: Nano Gen 2 body, chosen on the main menu and kept across launches
- assPod Model on the main menu switches between Video Gen 1 and Nano Gen 2; Color lists the
  current body's finishes (the nano: silver, green, blue, pink, black, (PRODUCT)RED). The choice is
  kept in ~/.config/glass-shell/asspod.json beside the glass tune.
- The nano: a procedural 2nd-generation model (tools/nano2-model) exported as render models, drawn
  by podd with anodized aluminum (tinted, bead-blasted, the laser-etched back paler), white end
  caps and a clickable wheel. Same menus on a 240 x 180 logical screen, so its text is larger, as on
  the real one; it plays no video. Shown 52.5 mm wide.
- podd: one box layout that holds either body, so a switch needs no restart; 64 px between atlas
  tiles and a 4 px clear band around each eye's image, which removes the hairline rectangle around
  the body (filtering at the stereo split took the other eye's edge); texture dump and debug modes.
- The left stick acts like the right: Steam's d-pad directions are no longer relayed (tilting the
  left stick down or left pressed the centre button or Menu).
2026-10-10 14:08:31 -04:00
104 changed files with 116966 additions and 293 deletions

No files matched your search

+6 -3
View File
@@ -50,6 +50,8 @@ Press the paintbrush button at the right end of the dashboard bar. Its panel set
Press the music note button on the dashboard bar, just left of the paintbrush. The assPod appears in the hand you pressed it with.
**assPod Model** on its main menu switches the body: **Video Gen 1** (the original) or **Nano Gen 2**, in anodized aluminum with white end caps. The nano has the same menus, laid out for its smaller screen with larger text (six rows, like the real one), and it plays no video. **Color** lists the current body's finishes: Black and White for the Video; Silver, Green, Blue, Pink, Black and (PRODUCT)RED for the nano. The model and colours you pick are kept across launches.
## Nothing persists
**The theme is never written to disk.** `lgs` injects the stylesheet into the *running* Steam client through its local devtools socket (`127.0.0.1:8080`; Steam on the Frame runs with `-cef-enable-debugging`). Any of these brings back the stock UI:
@@ -60,15 +62,16 @@ Press the music note button on the dashboard bar, just left of the paintbrush. T
Nothing starts at boot. There are no systemd units, autostart entries or Steam file patches.
Only two things stay installed, so the toggle is there after a reboot, plus one file of your own:
Only two things stay installed, so the toggle is there after a reboot, plus two files of your own:
| Path | What it is |
|---|---|
| `~/.local/share/applications/glass-shell.desktop` | The "LiquidAss" entry in + › Launch Program |
| `~/.local/share/glass-shell/` | The toggle script and the theme files it reads |
| `~/.config/glass-shell/tune.json` | Your glass settings from the paintbrush button on the bar (colour, intensity, refraction, frost, highlights). Written only when you change them; **Reset to default** in that panel deletes it. The one exception to "nothing persists", by request |
| `~/.config/glass-shell/tune.json` | Your glass settings from the paintbrush button on the bar (colour, intensity, refraction, frost, highlights). Written only when you change them; **Reset to default** in that panel deletes it. An exception to "nothing persists", by request |
| `~/.config/glass-shell/asspod.json` | The assPod model and colours you picked. Written only when you change them. The other exception, by request |
The [uninstall](#uninstall) command (or `python glass.py uninstall`) removes all three. (The install paths keep the project's original `glass-shell` name, so existing installs carry over.)
The [uninstall](#uninstall) command (or `python glass.py uninstall`) removes all of them. (The install paths keep the project's original `glass-shell` name, so existing installs carry over.)
## From the PC
@@ -0,0 +1,8 @@
newmtl nano_back_black
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_black_color.png
@@ -0,0 +1,63 @@
# nano_back_black: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_black.mtl
usemtl nano_back_black
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_blue
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_blue_color.png
@@ -0,0 +1,63 @@
# nano_back_blue: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_blue.mtl
usemtl nano_back_blue
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_green
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_green_color.png
@@ -0,0 +1,63 @@
# nano_back_green: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_green.mtl
usemtl nano_back_green
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_pink
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_pink_color.png
@@ -0,0 +1,63 @@
# nano_back_pink: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_pink.mtl
usemtl nano_back_pink
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_red
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_red_color.png
@@ -0,0 +1,63 @@
# nano_back_red: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_red.mtl
usemtl nano_back_red
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_silver
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_silver_color.png
@@ -0,0 +1,63 @@
# nano_back_silver: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_silver.mtl
usemtl nano_back_silver
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,8 @@
newmtl nano_cap_bottom
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.1 0.1 0.1
map_Kd nano_cap_bottom_color.png
@@ -0,0 +1,503 @@
# nano_cap_bottom: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_cap_bottom.mtl
usemtl nano_cap_bottom
v 0.000000 -0.044750 0.000000
v 0.016750 -0.044750 -0.002850
v 0.017122 -0.044750 -0.002826
v 0.000000 -0.044750 0.000000
v 0.017122 -0.044750 -0.002826
v 0.017488 -0.044750 -0.002753
v 0.000000 -0.044750 0.000000
v 0.017488 -0.044750 -0.002753
v 0.017841 -0.044750 -0.002633
v 0.000000 -0.044750 0.000000
v 0.017841 -0.044750 -0.002633
v 0.018175 -0.044750 -0.002468
v 0.000000 -0.044750 0.000000
v 0.018175 -0.044750 -0.002468
v 0.018485 -0.044750 -0.002261
v 0.000000 -0.044750 0.000000
v 0.018485 -0.044750 -0.002261
v 0.018765 -0.044750 -0.002015
v 0.000000 -0.044750 0.000000
v 0.018765 -0.044750 -0.002015
v 0.019011 -0.044750 -0.001735
v 0.000000 -0.044750 0.000000
v 0.019011 -0.044750 -0.001735
v 0.019218 -0.044750 -0.001425
v 0.000000 -0.044750 0.000000
v 0.019218 -0.044750 -0.001425
v 0.019383 -0.044750 -0.001091
v 0.000000 -0.044750 0.000000
v 0.019383 -0.044750 -0.001091
v 0.019503 -0.044750 -0.000738
v 0.000000 -0.044750 0.000000
v 0.019503 -0.044750 -0.000738
v 0.019576 -0.044750 -0.000372
v 0.000000 -0.044750 0.000000
v 0.019576 -0.044750 -0.000372
v 0.019600 -0.044750 0.000000
v 0.000000 -0.044750 0.000000
v 0.019600 -0.044750 0.000000
v 0.019576 -0.044750 0.000372
v 0.000000 -0.044750 0.000000
v 0.019576 -0.044750 0.000372
v 0.019503 -0.044750 0.000738
v 0.000000 -0.044750 0.000000
v 0.019503 -0.044750 0.000738
v 0.019383 -0.044750 0.001091
v 0.000000 -0.044750 0.000000
v 0.019383 -0.044750 0.001091
v 0.019218 -0.044750 0.001425
v 0.000000 -0.044750 0.000000
v 0.019218 -0.044750 0.001425
v 0.019011 -0.044750 0.001735
v 0.000000 -0.044750 0.000000
v 0.019011 -0.044750 0.001735
v 0.018765 -0.044750 0.002015
v 0.000000 -0.044750 0.000000
v 0.018765 -0.044750 0.002015
v 0.018485 -0.044750 0.002261
v 0.000000 -0.044750 0.000000
v 0.018485 -0.044750 0.002261
v 0.018175 -0.044750 0.002468
v 0.000000 -0.044750 0.000000
v 0.018175 -0.044750 0.002468
v 0.017841 -0.044750 0.002633
v 0.000000 -0.044750 0.000000
v 0.017841 -0.044750 0.002633
v 0.017488 -0.044750 0.002753
v 0.000000 -0.044750 0.000000
v 0.017488 -0.044750 0.002753
v 0.017122 -0.044750 0.002826
v 0.000000 -0.044750 0.000000
v 0.017122 -0.044750 0.002826
v 0.016750 -0.044750 0.002850
v 0.000000 -0.044750 0.000000
v 0.016750 -0.044750 0.002850
v -0.016750 -0.044750 0.002850
v 0.000000 -0.044750 0.000000
v -0.016750 -0.044750 0.002850
v -0.017122 -0.044750 0.002826
v 0.000000 -0.044750 0.000000
v -0.017122 -0.044750 0.002826
v -0.017488 -0.044750 0.002753
v 0.000000 -0.044750 0.000000
v -0.017488 -0.044750 0.002753
v -0.017841 -0.044750 0.002633
v 0.000000 -0.044750 0.000000
v -0.017841 -0.044750 0.002633
v -0.018175 -0.044750 0.002468
v 0.000000 -0.044750 0.000000
v -0.018175 -0.044750 0.002468
v -0.018485 -0.044750 0.002261
v 0.000000 -0.044750 0.000000
v -0.018485 -0.044750 0.002261
v -0.018765 -0.044750 0.002015
v 0.000000 -0.044750 0.000000
v -0.018765 -0.044750 0.002015
v -0.019011 -0.044750 0.001735
v 0.000000 -0.044750 0.000000
v -0.019011 -0.044750 0.001735
v -0.019218 -0.044750 0.001425
v 0.000000 -0.044750 0.000000
v -0.019218 -0.044750 0.001425
v -0.019383 -0.044750 0.001091
v 0.000000 -0.044750 0.000000
v -0.019383 -0.044750 0.001091
v -0.019503 -0.044750 0.000738
v 0.000000 -0.044750 0.000000
v -0.019503 -0.044750 0.000738
v -0.019576 -0.044750 0.000372
v 0.000000 -0.044750 0.000000
v -0.019576 -0.044750 0.000372
v -0.019600 -0.044750 0.000000
v 0.000000 -0.044750 0.000000
v -0.019600 -0.044750 0.000000
v -0.019576 -0.044750 -0.000372
v 0.000000 -0.044750 0.000000
v -0.019576 -0.044750 -0.000372
v -0.019503 -0.044750 -0.000738
v 0.000000 -0.044750 0.000000
v -0.019503 -0.044750 -0.000738
v -0.019383 -0.044750 -0.001091
v 0.000000 -0.044750 0.000000
v -0.019383 -0.044750 -0.001091
v -0.019218 -0.044750 -0.001425
v 0.000000 -0.044750 0.000000
v -0.019218 -0.044750 -0.001425
v -0.019011 -0.044750 -0.001735
v 0.000000 -0.044750 0.000000
v -0.019011 -0.044750 -0.001735
v -0.018765 -0.044750 -0.002015
v 0.000000 -0.044750 0.000000
v -0.018765 -0.044750 -0.002015
v -0.018485 -0.044750 -0.002261
v 0.000000 -0.044750 0.000000
v -0.018485 -0.044750 -0.002261
v -0.018175 -0.044750 -0.002468
v 0.000000 -0.044750 0.000000
v -0.018175 -0.044750 -0.002468
v -0.017841 -0.044750 -0.002633
v 0.000000 -0.044750 0.000000
v -0.017841 -0.044750 -0.002633
v -0.017488 -0.044750 -0.002753
v 0.000000 -0.044750 0.000000
v -0.017488 -0.044750 -0.002753
v -0.017122 -0.044750 -0.002826
v 0.000000 -0.044750 0.000000
v -0.017122 -0.044750 -0.002826
v -0.016750 -0.044750 -0.002850
v 0.000000 -0.044750 0.000000
v -0.016750 -0.044750 -0.002850
v 0.016750 -0.044750 -0.002850
vt 0.500000 0.500000
vt 0.918750 0.938462
vt 0.928050 0.934710
vt 0.500000 0.500000
vt 0.928050 0.934710
vt 0.937191 0.923521
vt 0.500000 0.500000
vt 0.937191 0.923521
vt 0.946016 0.905086
vt 0.500000 0.500000
vt 0.946016 0.905086
vt 0.954375 0.879719
vt 0.500000 0.500000
vt 0.954375 0.879719
vt 0.962124 0.847855
vt 0.500000 0.500000
vt 0.962124 0.847855
vt 0.969131 0.810039
vt 0.500000 0.500000
vt 0.969131 0.810039
vt 0.975276 0.766918
vt 0.500000 0.500000
vt 0.975276 0.766918
vt 0.980454 0.719231
vt 0.500000 0.500000
vt 0.980454 0.719231
vt 0.984576 0.667792
vt 0.500000 0.500000
vt 0.984576 0.667792
vt 0.987572 0.613482
vt 0.500000 0.500000
vt 0.987572 0.613482
vt 0.989390 0.557231
vt 0.500000 0.500000
vt 0.989390 0.557231
vt 0.990000 0.500000
vt 0.500000 0.500000
vt 0.990000 0.500000
vt 0.989390 0.442769
vt 0.500000 0.500000
vt 0.989390 0.442769
vt 0.987572 0.386518
vt 0.500000 0.500000
vt 0.987572 0.386518
vt 0.984576 0.332208
vt 0.500000 0.500000
vt 0.984576 0.332208
vt 0.980454 0.280769
vt 0.500000 0.500000
vt 0.980454 0.280769
vt 0.975276 0.233082
vt 0.500000 0.500000
vt 0.975276 0.233082
vt 0.969131 0.189961
vt 0.500000 0.500000
vt 0.969131 0.189961
vt 0.962124 0.152145
vt 0.500000 0.500000
vt 0.962124 0.152145
vt 0.954375 0.120281
vt 0.500000 0.500000
vt 0.954375 0.120281
vt 0.946016 0.094914
vt 0.500000 0.500000
vt 0.946016 0.094914
vt 0.937191 0.076479
vt 0.500000 0.500000
vt 0.937191 0.076479
vt 0.928050 0.065290
vt 0.500000 0.500000
vt 0.928050 0.065290
vt 0.918750 0.061538
vt 0.500000 0.500000
vt 0.918750 0.061538
vt 0.081250 0.061538
vt 0.500000 0.500000
vt 0.081250 0.061538
vt 0.071950 0.065290
vt 0.500000 0.500000
vt 0.071950 0.065290
vt 0.062809 0.076479
vt 0.500000 0.500000
vt 0.062809 0.076479
vt 0.053984 0.094914
vt 0.500000 0.500000
vt 0.053984 0.094914
vt 0.045625 0.120281
vt 0.500000 0.500000
vt 0.045625 0.120281
vt 0.037876 0.152145
vt 0.500000 0.500000
vt 0.037876 0.152145
vt 0.030869 0.189961
vt 0.500000 0.500000
vt 0.030869 0.189961
vt 0.024724 0.233082
vt 0.500000 0.500000
vt 0.024724 0.233082
vt 0.019546 0.280769
vt 0.500000 0.500000
vt 0.019546 0.280769
vt 0.015424 0.332208
vt 0.500000 0.500000
vt 0.015424 0.332208
vt 0.012428 0.386518
vt 0.500000 0.500000
vt 0.012428 0.386518
vt 0.010610 0.442769
vt 0.500000 0.500000
vt 0.010610 0.442769
vt 0.010000 0.500000
vt 0.500000 0.500000
vt 0.010000 0.500000
vt 0.010610 0.557231
vt 0.500000 0.500000
vt 0.010610 0.557231
vt 0.012428 0.613482
vt 0.500000 0.500000
vt 0.012428 0.613482
vt 0.015424 0.667792
vt 0.500000 0.500000
vt 0.015424 0.667792
vt 0.019546 0.719231
vt 0.500000 0.500000
vt 0.019546 0.719231
vt 0.024724 0.766918
vt 0.500000 0.500000
vt 0.024724 0.766918
vt 0.030869 0.810039
vt 0.500000 0.500000
vt 0.030869 0.810039
vt 0.037876 0.847855
vt 0.500000 0.500000
vt 0.037876 0.847855
vt 0.045625 0.879719
vt 0.500000 0.500000
vt 0.045625 0.879719
vt 0.053984 0.905086
vt 0.500000 0.500000
vt 0.053984 0.905086
vt 0.062809 0.923521
vt 0.500000 0.500000
vt 0.062809 0.923521
vt 0.071950 0.934710
vt 0.500000 0.500000
vt 0.071950 0.934710
vt 0.081250 0.938462
vt 0.500000 0.500000
vt 0.081250 0.938462
vt 0.918750 0.938462
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
f 19/19/19 20/20/20 21/21/21
f 22/22/22 23/23/23 24/24/24
f 25/25/25 26/26/26 27/27/27
f 28/28/28 29/29/29 30/30/30
f 31/31/31 32/32/32 33/33/33
f 34/34/34 35/35/35 36/36/36
f 37/37/37 38/38/38 39/39/39
f 40/40/40 41/41/41 42/42/42
f 43/43/43 44/44/44 45/45/45
f 46/46/46 47/47/47 48/48/48
f 49/49/49 50/50/50 51/51/51
f 52/52/52 53/53/53 54/54/54
f 55/55/55 56/56/56 57/57/57
f 58/58/58 59/59/59 60/60/60
f 61/61/61 62/62/62 63/63/63
f 64/64/64 65/65/65 66/66/66
f 67/67/67 68/68/68 69/69/69
f 70/70/70 71/71/71 72/72/72
f 73/73/73 74/74/74 75/75/75
f 76/76/76 77/77/77 78/78/78
f 79/79/79 80/80/80 81/81/81
f 82/82/82 83/83/83 84/84/84
f 85/85/85 86/86/86 87/87/87
f 88/88/88 89/89/89 90/90/90
f 91/91/91 92/92/92 93/93/93
f 94/94/94 95/95/95 96/96/96
f 97/97/97 98/98/98 99/99/99
f 100/100/100 101/101/101 102/102/102
f 103/103/103 104/104/104 105/105/105
f 106/106/106 107/107/107 108/108/108
f 109/109/109 110/110/110 111/111/111
f 112/112/112 113/113/113 114/114/114
f 115/115/115 116/116/116 117/117/117
f 118/118/118 119/119/119 120/120/120
f 121/121/121 122/122/122 123/123/123
f 124/124/124 125/125/125 126/126/126
f 127/127/127 128/128/128 129/129/129
f 130/130/130 131/131/131 132/132/132
f 133/133/133 134/134/134 135/135/135
f 136/136/136 137/137/137 138/138/138
f 139/139/139 140/140/140 141/141/141
f 142/142/142 143/143/143 144/144/144
f 145/145/145 146/146/146 147/147/147
f 148/148/148 149/149/149 150/150/150
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

@@ -0,0 +1,8 @@
newmtl nano_cap_top
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.1 0.1 0.1
map_Kd nano_cap_top_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,8 @@
newmtl nano_center_black
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_black_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_center_blue
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_blue_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_center_green
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_green_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_center_pink
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_pink_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_center_red
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_red_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

@@ -0,0 +1,8 @@
newmtl nano_center_silver
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_silver_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

@@ -0,0 +1,8 @@
newmtl nano_ports
Kd 1 1 1
Ns 256
d 1
illum 2
Ka 0 0 0
Ks 0.2 0.2 0.2
map_Kd nano_ports_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.
@@ -0,0 +1,8 @@
newmtl nano_shell_black
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_black_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_shell_blue
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_blue_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_shell_green
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_green_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_shell_pink
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_pink_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_shell_red
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_red_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

@@ -0,0 +1,8 @@
newmtl nano_shell_silver
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_silver_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

@@ -0,0 +1,8 @@
newmtl nano_well
Kd 1 1 1
Ns 256
d 1
illum 2
Ka 0 0 0
Ks 0 0 0
map_Kd nano_well_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

@@ -0,0 +1,8 @@
newmtl nano_wheel_black
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.08 0.08 0.08
map_Kd nano_wheel_black_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,8 @@
newmtl nano_wheel_white
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.08 0.08 0.08
map_Kd nano_wheel_white_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+43 -2
View File
@@ -4,6 +4,11 @@
//
// The static body is drawn once per finish into a layer; a frame blits it, then the screen and the
// wheel state. draw() returns false when nothing changed, so the page repaints only on change.
//
// The nano (models.js) has only its screen here, in its window, filling most of the canvas: podd or
// its render models draw the rest, and the page shows just the window's rect (windowRect('nano')).
import { MODELS } from './models.js';
export const BODY = { w: 61.8, h: 103.5, corner: 6.5 };
const LCD = { cy: 24.2, w: 50.8, h: 38.1 };
@@ -52,6 +57,41 @@ export function createFace(canvas, screen, opts = {}) {
const lcd = { x: X(-LCD.w / 2), y: Y(LCD.cy + LCD.h / 2), w: LCD.w * k, h: LCD.h * k };
const wc = { x: X(0), y: Y(WHEEL.cy) };
// the nano's window: 90% of the canvas's width, centred
const NANO = MODELS.nano;
const nk = W * 0.9 / NANO.win.w; // px per mm
const nanoWin = { w: NANO.win.w * nk, h: NANO.win.h * nk };
nanoWin.x = (W - nanoWin.w) / 2;
nanoWin.y = (H - nanoWin.h) / 2;
const nanoLcd = { w: NANO.lcd.w * nk, h: NANO.lcd.h * nk };
nanoLcd.x = (W - nanoLcd.w) / 2;
nanoLcd.y = (H - nanoLcd.h) / 2;
/** The model's screen window in this canvas (px): what the page shows over the window. */
function windowRect(model) {
if (model === 'nano') return { ...nanoWin };
return { x: X(-WINDOW.w / 2), y: Y(LCD.cy + WINDOW.h / 2), w: WINDOW.w * k, h: WINDOW.h * k };
}
// The nano: its LCD in a black window (rounded like the nano's), the backlight and the glass.
function drawNano(s) {
roundRect(g, nanoWin.x, nanoWin.y, nanoWin.w, nanoWin.h, 0.6 * nk);
g.fillStyle = '#000';
g.fill();
g.imageSmoothingEnabled = true;
g.imageSmoothingQuality = 'high';
g.drawImage(screen, nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
const veil = 1 - Math.max(0, Math.min(1, s.backlight));
if (veil > 0.001) {
g.fillStyle = `rgba(0,0,0,${veil})`;
g.fillRect(nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
}
const refl = g.createLinearGradient(nanoLcd.x, nanoLcd.y, nanoLcd.x + nanoLcd.w, nanoLcd.y + nanoLcd.h);
refl.addColorStop(0, 'rgba(255,255,255,0.07)');
refl.addColorStop(0.5, 'rgba(255,255,255,0)');
g.fillStyle = refl;
g.fillRect(nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
}
function bodyLayer(finish) {
let c = layers.get(finish);
@@ -204,12 +244,13 @@ export function createFace(canvas, screen, opts = {}) {
if (marker !== target) {
marker = target > marker ? Math.min(1, marker + dt / MARKER_IN) : Math.max(0, marker - dt / MARKER_OUT);
}
const key = [s.finish, Math.round(s.backlight * 100), s.pressed || '', marker > 0 ? Math.round(s.angle * 200) : -1,
const key = [s.model || 'video', s.finish, Math.round(s.backlight * 100), s.pressed || '', marker > 0 ? Math.round(s.angle * 200) : -1,
Math.round(marker * 20), s.reflVersion || 0, s.screenOnly ? 1 : 0, s.videoUnder ? 1 : 0].join('|');
if (!s.screenDirty && key === last) return false;
last = key;
const f = FINISHES[s.finish] || FINISHES.black;
g.clearRect(0, 0, W, H);
if (s.model === 'nano') { drawNano(s); return true; }
if (overlay) {
roundRect(g, X(-WINDOW.w / 2), Y(LCD.cy + WINDOW.h / 2), WINDOW.w * k, WINDOW.h * k, WINDOW.r * k);
g.fillStyle = '#000';
@@ -252,7 +293,7 @@ export function createFace(canvas, screen, opts = {}) {
return true;
}
return { draw, get animating() { return marker > 0 && marker < 1; } };
return { draw, windowRect, get animating() { return marker > 0 && marker < 1; } };
}
/** The mirror-polished steel back with the peach engraved in it (static: drawn once). Polished
+43 -14
View File
@@ -1,5 +1,6 @@
// assPod: a 5th-generation-style music player in your hand in the SteamVR menu, playing the VLC
// media library. This is its half inside SteamVR's systemui page (installed by the daemon as the
// media library. The main menu's assPod Model swaps the body for a 2nd-generation nano (models.js):
// the same OS, scaled onto its smaller screen, and no video. This is its half inside SteamVR's systemui page (installed by the daemon as the
// page script device/vr/systemui.asspod.js, built from these sources by tools/build_asspod.py);
// the daemon's plugin device/shell_ext/asspod.py runs VLC and reads the library.
//
@@ -12,8 +13,9 @@
// Controls: both sticks turn the click wheel; a stick click presses the wheel button under it
// (centred: the centre button). A is the centre button, B is the wheel's Menu, and the hamburger
// (menu) button puts the assPod away; so does closing the SteamVR menu. The right controller's
// buttons are read here from its model; the left one's (A and B in the same places) come from Steam
// through the plugin (btn()). Music keeps playing: the player keeps running here at a few Hz, and
// buttons are read here from its model, Steam's A / B / hamburger come through the plugin (btn())
// otherwise. Both sticks act alike: only their models are read (Steam's d-pad directions, which the
// left stick's tilts also send, are not relayed). Music keeps playing: the player keeps running here at a few Hz, and
// the plugin keeps VLC up while anything plays and stops it when the assPod is away and nothing plays.
//
// The plugin talks to this page only through the returned api: poll() drains the commands for VLC
@@ -33,6 +35,7 @@ import { createFace, createBack, BODY } from './face.js';
import { drawPeachSilhouette } from './brand/peach.js';
import { createReflector, pose, qmul, qrot, qconj } from './reflect.js';
import { createSceneGraph } from './sg.js';
import { MODELS, currentModel, currentFinish, bodyMetres } from './models.js';
const FACE_W = 476; // px; the body is 61.8 x 103.5 mm
const FACE_H = Math.round(FACE_W * BODY.h / BODY.w);
@@ -40,6 +43,10 @@ const FACE_WIDTH_M = 0.085; // shown about 1.4x the real body, fo
const BODY_M = { // the 3D body in metres, at that scale
w: FACE_WIDTH_M, h: FACE_WIDTH_M * BODY.h / BODY.w, d: FACE_WIDTH_M * 11 / BODY.w, r: FACE_WIDTH_M * BODY.corner / BODY.w,
};
const NANO_M = bodyMetres(MODELS.nano); // the nano, at its own scale
// a model's geometry for the scene graph (model metres)
const geoOf = (m) => ({ w: m.body.w / 1000, d: m.body.d / 1000, lcdCy: m.lcd.cy / 1000, lcdW: m.lcd.w / 1000,
winW: m.win.w / 1000, winH: m.win.h / 1000, wheelCy: m.wheel.cy / 1000 });
const BACK_W = 238; // px: the steel back's live reflection (half the face's resolution)
const BACK_H = Math.round(BACK_W * BODY.h / BODY.w);
const REFLECT_EVERY = 2; // frames between reflection updates (about 30 a second)
@@ -151,9 +158,15 @@ export default function install(ctx) {
library.onLoad(() => { art.clear(); os.ctx.popToRoot(); os.invalidate(); });
// the EQ follows the setting (VLC's preset by name)
settings.on('change', ({ key }) => { if (key === 'eq' || key === '*') send({ op: 'eq', name: settings.get('eq') }); });
// the body's colour: podd follows the finish in the poll; the render-model body is rebuilt
// The body: podd follows the model and its finish in the poll (one layout holds both); the scene
// graph is rebuilt for the other body's screen panel and place in the hand, or for the render
// models' colour. (Picking the nano stops a video: os/screens/main-menu.js.)
settings.on('change', ({ key }) => {
if ((key === 'finish' || key === '*') && st.open && !st.podd) st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
if (!st.open) return;
if (key === 'model' || key === '*' || (!st.podd && (key === 'finish' || key === 'nanoFinish'))) {
if (key === 'model' && st.world) unpin(false);
st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
}
});
// ---------------------------------------------------------------- state
@@ -246,11 +259,13 @@ export default function install(ctx) {
videoMode();
const screenDirty = os.render(t);
// the painted reflections only without podd (podd shades the glass and the steel itself)
if (!st.podd && st.frames % REFLECT_EVERY === 0 && reflector.update(st.hand, st.holdPose)) back.draw(reflector.back, reflector.version);
const model = currentModel(settings).id;
const painted = !st.podd && model === 'video'; // the nano's render models carry their own back
if (painted && st.frames % REFLECT_EVERY === 0 && reflector.update(st.hand, st.holdPose)) back.draw(reflector.back, reflector.version);
const ws = wheel.state;
face.draw({
reflection: !st.podd && reflector.version ? reflector.front : null, reflVersion: reflector.version, screenOnly: !!st.podd, videoUnder: !!st.videoMode,
finish: settings.get('finish'), backlight: os.backlight, screenDirty,
reflection: painted && reflector.version ? reflector.front : null, reflVersion: reflector.version, screenOnly: !!st.podd, videoUnder: !!st.videoMode,
model, finish: currentFinish(settings), backlight: os.backlight, screenDirty,
touching: ws.touching, angle: ws.angle, pressed: ws.pressedPart || (st.keys.select ? 'select' : st.keys.menu ? 'menu' : null), now: t,
});
} catch (err) { note(err); }
@@ -268,9 +283,15 @@ export default function install(ctx) {
if (player.state === 'playing') st.bgTimer = setTimeout(background, BACKGROUND_MS);
}
// Reposition's places are kept per body: the Video's under the hand's name, the nano's as nano-<hand>
const holdKey = (hand) => (currentModel(settings).id === 'video' ? hand : `${currentModel(settings).id}-${hand}`);
function showOpts(hand, world) {
return { hand, body: BODY_M, rects, modelDir: st.modelDir, finish: settings.get('finish'), podd: st.podd,
hold: st.holds[hand] || null, world: world || null, video: !!st.videoMode };
const m = currentModel(settings);
const wr = face.windowRect(m.id);
rects.win = { x: rects.face.x + wr.x, y: rects.face.y + wr.y, w: wr.w, h: wr.h };
return { hand, body: m.id === 'video' ? BODY_M : NANO_M, rects, modelDir: st.modelDir, model: m.id, geo: geoOf(m),
finish: currentFinish(settings), podd: st.podd, hold: st.holds[holdKey(hand)] || null, world: world || null, video: !!st.videoMode };
}
// A video on the screen with podd: podd shows its picture (the daemon's frames, full rate) in a panel
@@ -302,7 +323,7 @@ export default function install(ctx) {
const hp = pose('/user/hand/' + st.hand);
if (hp) {
const inv = qconj(hp.q);
st.holds[st.hand] = {
st.holds[holdKey(st.hand)] = {
rot: qmul(inv, st.world.rot),
centre: qrot(inv, st.world.centre.map((v, i) => v - hp.t[i])),
};
@@ -317,6 +338,7 @@ export default function install(ctx) {
return {
id: 'reposition',
title: 'Reposition',
adaptive: true,
keepAwake: true,
enter() { if (!pin()) note('reposition: no hand pose'); },
exit() { if (st.world) unpin(false); },
@@ -325,7 +347,7 @@ export default function install(ctx) {
if (e.type !== 'press') return e.type === 'release' ? false : undefined;
if (e.part === 'select') return done(() => unpin(true));
if (e.part === 'menu') return done(() => unpin(false));
if (e.part === 'play') return done(() => { st.holds[st.hand] = null; unpin(false); });
if (e.part === 'play') return done(() => { st.holds[holdKey(st.hand)] = null; unpin(false); });
return false;
},
draw(g, r) {
@@ -379,6 +401,10 @@ export default function install(ctx) {
for (const [p, t] of Object.entries(opts.resume)) if (Number.isFinite(t)) resume.set(p, t);
}
if (opts.holds && typeof opts.holds === 'object') st.holds = Object.assign({ left: null, right: null }, opts.holds);
// the body and colours picked last time (the plugin keeps them: device/shell_ext/asspod.py PREFS)
if (opts.prefs && typeof opts.prefs === 'object') {
for (const key of ['model', 'finish', 'nanoFinish']) if (typeof opts.prefs[key] === 'string') settings.set(key, opts.prefs[key]);
}
st.world = null;
st.videoMode = false;
art.videoExternal = false;
@@ -436,7 +462,8 @@ export default function install(ctx) {
function status() {
return {
ok: true, open: st.open, hand: st.hand, closedBy: st.closedBy, loading: st.loading,
hold: st.holdPose || null, bodyScale: BODY_M.w / 0.0618, finish: settings.get('finish'), podd: !!st.podd,
hold: st.holdPose || null, bodyScale: BODY_M.w / 0.0618, nanoScale: MODELS.nano.scale, model: currentModel(settings).id,
finish: currentFinish(settings), podd: !!st.podd,
player: { state: player.state, current: player.current ? { id: player.current.id, title: player.current.title } : null,
position: Math.round((player.position || 0) * 10) / 10, queue: player.queue.length, volume: player.volume },
library: { tracks: library.tracks.length, albums: library.albums.length, videos: library.videos.length },
@@ -452,6 +479,7 @@ export default function install(ctx) {
os.ctx.push({
id: 'notice',
title: String(title || 'assPod'),
adaptive: true,
handle(e) {
if (e.type === 'press' && (e.part === 'select' || e.part === 'menu')) { os.ctx.pop(); return true; }
return e.type === 'scroll' ? false : undefined;
@@ -493,7 +521,8 @@ export default function install(ctx) {
playing: player.state === 'playing', state: player.state,
current: player.current ? player.current.path : null,
volume: player.volume, eq: settings.get('eq'),
hold: st.holdPose || null, world: st.world, holds: st.holds, finish: settings.get('finish'),
hold: st.holdPose || null, world: st.world, holds: st.holds, model: currentModel(settings).id, finish: currentFinish(settings),
prefs: { model: currentModel(settings).id, finish: settings.get('finish'), nanoFinish: settings.get('nanoFinish') },
position: Math.round((player.position || 0) * 1000) / 1000,
video: player.current && player.current.kind === 'video' && os.ctx.top()?.id === 'videoplayer' && st.open,
};
+60
View File
@@ -0,0 +1,60 @@
// The assPod's bodies: the Video (5th generation, the original) and the nano (2nd generation). The
// main menu's assPod Model picks one; Color picks among that body's own finishes. Both run the same
// OS on a 4:3 screen; the nano's is 240 x 180 logical px against the Video's 320 x 240, so its text is
// larger on its screen, as on the real one. The nano plays no video: its menus leave the Videos out.
//
// Geometry in millimetres, model frame (tools/asspod-model, tools/nano2-model GEOMETRY): origin at
// the body's centre, y toward the top. `scale` is how much larger than life it is shown in the hand;
// podd (native/podd/podd.cpp VIDEO / NANO) and its render models use the same numbers. `screen` is
// the OS's logical screen on that body.
export const MODELS = Object.freeze({
video: Object.freeze({
id: 'video',
label: 'Video Gen 1',
about: 'assPod Video',
body: { w: 61.8, h: 103.5, d: 11.0 },
lcd: { cy: 24.2, w: 50.8, h: 38.1 },
win: { w: 53.2, h: 40.5 },
wheel: { cy: -24.0 },
screen: { w: 320, h: 240 }, // logical px (os/theme.js setScreenSize)
scale: 0.085 / 0.0618, // shown 85 mm wide
finishKey: 'finish',
finishes: [['black', 'Black'], ['white', 'White']],
videos: true,
}),
nano: Object.freeze({
id: 'nano',
label: 'Nano Gen 2',
about: 'assPod nano',
body: { w: 40.0, h: 90.0, d: 6.5 },
lcd: { cy: 28.88, w: 29.6, h: 22.2 },
win: { w: 31.62, h: 24.04 },
wheel: { cy: -13.3 },
screen: { w: 240, h: 180 }, // the same text on less screen: six rows, as on the real nano
scale: 1.3125, // shown 52.5 mm wide
finishKey: 'nanoFinish',
finishes: [['silver', 'Silver'], ['green', 'Green'], ['blue', 'Blue'], ['pink', 'Pink'], ['black', 'Black'], ['red', '(PRODUCT)RED']],
videos: false,
}),
});
export const MODEL_ORDER = ['video', 'nano'];
/** The current body's definition. */
export function currentModel(settings) {
return MODELS[settings.get('model')] || MODELS.video;
}
/** The current body's finish (one of its own; its first otherwise). */
export function currentFinish(settings) {
const m = currentModel(settings);
const f = settings.get(m.finishKey);
return m.finishes.some(([k]) => k === f) ? f : m.finishes[0][0];
}
/** The body in metres as shown in the hand: { w, h, d } at the model's scale. */
export function bodyMetres(m) {
const k = m.scale / 1000;
return { w: m.body.w * k, h: m.body.h * k, d: m.body.d * k };
}
+4
View File
@@ -66,7 +66,11 @@ export function createMenu(ctx, def) {
}
function refresh() {
// the highlight stays on its item when the list changes around it (the main menu without Videos)
const was = items[index];
items = resolve(def.items) ?? [];
const j = was ? items.indexOf(was) : -1;
if (j >= 0) index = j;
clampWindow();
}
+29 -9
View File
@@ -1,8 +1,10 @@
// assPod OS core: screen stack, 220 ms slide transitions, status bar, backlight timer and dimming,
// sleep/wake, the boot logo, the default button behavior, and render throttling for the screen
// texture. Draws the 320x240 logical LCD onto the 640x480 canvas that main.js uploads as a texture.
// texture. Draws the logical LCD (320x240 on the Video, 240x180 on the nano) onto the 640x480 canvas
// that main.js uploads as a texture.
import { SCREEN_W } from '../config.js';
import { currentModel } from '../models.js';
import { settings as defaultSettings } from '../settings.js';
import { drawPeachSilhouette } from '../brand/peach.js';
import * as theme from './theme.js';
@@ -11,7 +13,7 @@ import { createMenu } from './menu.js';
import { createMainMenu } from './screens/main-menu.js';
import { createNowPlaying } from './screens/now-playing.js';
const { M, C, ANIM } = theme;
const { M, C, ANIM, setScreenSize } = theme;
/**
* @typedef {import('../input/clickwheel.js').WheelEvent} WheelEvent
@@ -44,8 +46,9 @@ const { M, C, ANIM } = theme;
// ---------------------------------------------------------------- constants
const CONTENT = Object.freeze({ x: 0, y: M.statusH, w: M.W, h: M.H - M.statusH });
const FULL = Object.freeze({ x: 0, y: 0, w: M.W, h: M.H });
// the content area under the status bar, and the whole screen (the logical size can change: models.js)
const content = () => ({ x: 0, y: M.statusH, w: M.W, h: M.H - M.statusH });
const full = () => ({ x: 0, y: 0, w: M.W, h: M.H });
const FRAME_MS = 1000 / ANIM.maxFps;
// rAF timestamps jitter by a fraction of a ms; without slack a 25 ms cap would skip a frame at 80 Hz.
const FRAME_SLACK_MS = 1;
@@ -74,7 +77,12 @@ function normKey(s) {
*/
export function createOS({ canvas, player, library, art, apps, settings = defaultSettings, boot: bootEnabled = false }) {
const g = canvas.getContext('2d');
const scale = canvas.width / SCREEN_W;
// The body's logical screen (models.js): the canvas stays the same size and the scale follows.
const applyModel = () => { const m = currentModel(settings); setScreenSize(m.screen.w, m.screen.h); };
applyModel();
if (typeof settings.on === 'function') {
settings.on('change', ({ key }) => { if (key === 'model' || key === '*') { applyModel(); dirty = DIRTY_NOW; } });
}
/** @type {Screen[]} */
const stack = [];
@@ -360,8 +368,18 @@ export function createOS({ canvas, player, library, art, apps, settings = defaul
}
const reported = new WeakSet();
// Menus and the screens marked `adaptive` lay themselves out on the current logical screen; the
// rest (the games, the clock, Cover Flow...) are drawn for the Video's 320 x 240 and scaled to fit.
function drawScreen(s, r, now) {
try {
if (!s.isMenu && !s.adaptive && M.W !== SCREEN_W) {
const k = M.W / SCREEN_W;
g.save();
g.scale(k, k);
s.draw(g, { x: r.x / k, y: r.y / k, w: r.w / k, h: r.h / k }, now);
g.restore();
return;
}
s.draw(g, r, now);
} catch (err) {
// One broken screen must not take the whole OS down every frame; report it once.
@@ -373,12 +391,13 @@ export function createOS({ canvas, player, library, art, apps, settings = defaul
if (dx <= -M.W || dx >= M.W) return;
g.save();
g.beginPath();
g.rect(CONTENT.x, CONTENT.y, CONTENT.w, CONTENT.h);
const r = content();
g.rect(r.x, r.y, r.w, r.h);
g.clip();
g.translate(dx, 0);
g.fillStyle = C.bg;
g.fillRect(CONTENT.x, CONTENT.y, CONTENT.w, CONTENT.h);
drawScreen(s, CONTENT, now);
g.fillRect(r.x, r.y, r.w, r.h);
drawScreen(s, r, now);
g.restore();
}
@@ -397,7 +416,7 @@ export function createOS({ canvas, player, library, art, apps, settings = defaul
g.fillStyle = '#000000';
g.fillRect(0, 0, M.W, M.H);
}
drawScreen(s, FULL, now);
drawScreen(s, full(), now);
g.restore();
return;
}
@@ -409,6 +428,7 @@ export function createOS({ canvas, player, library, art, apps, settings = defaul
}
function paint(now) {
const scale = canvas.width / M.W;
g.setTransform(scale, 0, 0, scale, 0, 0);
g.globalAlpha = 1;
g.globalCompositeOperation = 'source-over';
+70 -14
View File
@@ -4,6 +4,7 @@
import { createRng, hashString } from '../../util/rng.js';
import { createSettings } from './settings.js';
import { createSearch } from './search.js';
import { MODELS, MODEL_ORDER, currentModel, currentFinish } from '../../models.js';
// ---------------------------------------------------------------- sorting
@@ -32,7 +33,9 @@ function albumOrder(library) {
* A list of songs (or videos, in playlists). Selecting row i plays the whole list from i,
* the way the assPod queues whatever list you picked from, then opens Now Playing.
*/
export function songList(ctx, id, title, list) {
export function songList(ctx, id, title, all) {
// the nano plays no video: a playlist's videos are left out on it
const list = currentModel(ctx.settings).videos ? all : all.filter((t) => t.kind !== 'video');
const items = list.map((t, i) => ({
label: t.title,
speaker: () => ctx.player.current?.id === t.id && ctx.player.state !== 'stopped',
@@ -170,30 +173,83 @@ function shuffleSongs(ctx) {
return true;
}
// ---------------------------------------------------------------- the body: model and colour
/** assPod Model: the Video or the nano, checkmarked; picking one switches the body and goes back.
* The nano plays no video: a video playing stops when it is picked. */
function modelMenu(ctx) {
return ctx.menu({
id: 'model',
title: 'assPod Model',
initialIndex: () => Math.max(0, MODEL_ORDER.indexOf(currentModel(ctx.settings).id)),
items: MODEL_ORDER.map((id) => ({
label: MODELS[id].label,
check: () => currentModel(ctx.settings).id === id,
action: (c) => {
if (!MODELS[id].videos && c.player.current?.kind === 'video') c.player.stop();
c.settings.set('model', id);
c.pop();
return true;
},
})),
});
}
/** Color: the current body's own finishes, checkmarked. */
function colorMenu(ctx) {
const m = currentModel(ctx.settings);
return ctx.menu({
id: 'color',
title: 'Color',
initialIndex: () => Math.max(0, m.finishes.findIndex(([k]) => k === currentFinish(ctx.settings))),
items: m.finishes.map(([key, label]) => ({
label,
check: () => currentFinish(ctx.settings) === key,
action: (c) => {
c.settings.set(m.finishKey, key);
c.pop();
return true;
},
})),
});
}
// ---------------------------------------------------------------- main menu
export function createMainMenu(ctx) {
const base = [
const finishLabel = () => {
const m = currentModel(ctx.settings);
const f = currentFinish(ctx.settings);
return (m.finishes.find(([k]) => k === f) || m.finishes[0])[1];
};
const all = [
{ id: 'music', label: 'Music', open: createMusicMenu },
{ id: 'videos', label: 'Videos', open: (c) => c.apps.videos(c) },
{ id: 'extras', label: 'Extras', open: (c) => c.apps.extras(c) },
{ id: 'settings', label: 'Settings', open: createSettings },
{ id: 'reposition', label: 'Reposition', open: (c) => c.apps.reposition(c) },
{
id: 'color',
label: 'Color',
detail: () => (ctx.settings.get('finish') === 'white' ? 'White' : 'Black'),
action: (c) => { c.settings.set('finish', c.settings.get('finish') === 'white' ? 'black' : 'white'); return true; },
},
{ id: 'model', label: 'assPod Model', detail: () => currentModel(ctx.settings).label, open: modelMenu },
{ id: 'color', label: 'Color', detail: finishLabel, open: colorMenu },
{ id: 'shuffle', label: 'Shuffle Songs', action: shuffleSongs },
{ id: 'backlight', label: 'Backlight', action: (c) => { c.os.toggleBacklight(); return false; } },
];
const withNowPlaying = [
...base,
{ id: 'nowplaying', label: 'Now Playing', chevron: true, action: (c) => { c.nowPlaying(); return true; } },
];
const nowPlaying = { id: 'nowplaying', label: 'Now Playing', chevron: true, action: (c) => { c.nowPlaying(); return true; } };
// the same item objects every time, so the menu keeps its place across a model switch
const lists = new Map();
return ctx.menu({
id: 'main',
title: 'assPod',
// Like the 5G, Now Playing is listed only while something is queued (not after the queue ends).
items: () => (ctx.player.current && ctx.player.state !== 'stopped' ? withNowPlaying : base),
// Like the 5G, Now Playing is listed only while something is queued (not after the queue ends);
// the nano has no Videos.
items: () => {
const videos = currentModel(ctx.settings).videos;
const playing = !!(ctx.player.current && ctx.player.state !== 'stopped');
const key = `${videos}|${playing}`;
if (!lists.has(key)) {
const base = videos ? all : all.filter((it) => it.id !== 'videos');
lists.set(key, playing ? [...base, nowPlaying] : base);
}
return lists.get(key);
},
});
}
+33 -11
View File
@@ -2,23 +2,40 @@
// artist / album on the right, and a progress bar with elapsed and remaining time.
// Center click cycles progress -> scrubber -> rating; the wheel sets volume, seeks or rates.
import { SCREEN_SCALE } from '../../config.js';
import { SCREEN_W, SCREEN_SCALE } from '../../config.js';
import { M } from '../theme.js';
// ---------------------------------------------------------------- layout
const POS = { x: 10, y: 40 };
const ART = { x: 10, y: 48, s: 112 };
const TEXT = { x: 132, w: 180, title: 82, artist: 102, album: 122 };
const BAR = { x: 10, y: 182, w: 300, h: 10 };
const TIME_Y = 208;
const STARS = { cx: 160, cy: 187 };
const VOLUME = { x: 10, y: 180, w: 300 };
// The Video's 320 x 240 layout, and the nano's on its 240 x 180 screen (the same text, less room:
// smaller art and a shorter bar). relayout() picks the one for the current logical screen.
const LAYOUTS = {
320: {
POS: { x: 10, y: 40 }, ART: { x: 10, y: 48, s: 112 }, TEXT: { x: 132, w: 180, title: 82, artist: 102, album: 122 },
BAR: { x: 10, y: 182, w: 300, h: 10 }, TIME_Y: 208, STARS: { cx: 160, cy: 187 }, VOLUME: { x: 10, y: 180, w: 300 },
GLYPH: { right: 310, w: 12, h: 9, gap: 5 },
},
240: {
POS: { x: 8, y: 38 }, ART: { x: 8, y: 46, s: 80 }, TEXT: { x: 96, w: 136, title: 66, artist: 84, album: 102 },
BAR: { x: 8, y: 136, w: 224, h: 9 }, TIME_Y: 160, STARS: { cx: 120, cy: 141 }, VOLUME: { x: 8, y: 134, w: 224 },
GLYPH: { right: 232, w: 12, h: 9, gap: 5 },
},
};
let POS, ART, TEXT, BAR, TIME_Y, STARS, VOLUME, GLYPH, ART_PX;
let laidOut = 0;
function relayout() {
if (laidOut === M.W) return;
laidOut = M.W;
({ POS, ART, TEXT, BAR, TIME_Y, STARS, VOLUME, GLYPH } = LAYOUTS[M.W] || LAYOUTS[320]);
// the cover is resampled to the art box's backing-store size (the canvas is 640 px wide)
ART_PX = Math.round(ART.s * SCREEN_W * SCREEN_SCALE / M.W);
}
relayout();
const VOLUME_STEPS = 16;
const SCRUB_STEP_S = 2;
const SCRUB_FAST_MS = 120; // steps closer than this count as "scrolling fast"
const MODES = ['progress', 'scrub', 'rating'];
const GLYPH = { right: 310, w: 12, h: 9, gap: 5 }; // shuffle / repeat marks, right of "n of N"
/** Grey square with a beamed note, for items without cover art. */
function drawPlaceholderArt(g, x, y, s) {
@@ -94,14 +111,14 @@ function drawRepeatGlyph(g, x, y, color, one, oneFont) {
// The 256px cover is resampled once per album to the art box's backing-store size (224px), so
// each 4 Hz (or 40 fps marquee) redraw is a 1:1 blit instead of a high-quality downscale.
const ART_PX = ART.s * SCREEN_SCALE;
let artKey = null;
let artPx = 0;
let artSrc = null;
let artCanvas = null;
function scaledCover(src, key) {
// The source canvas counts too: art.clear() redraws covers (late web fonts) as new canvases.
if (key === artKey && src === artSrc && artCanvas) return artCanvas;
if (key === artKey && src === artSrc && artCanvas && artPx === ART_PX) return artCanvas;
if (typeof document === 'undefined') return src;
artCanvas ??= document.createElement('canvas');
artCanvas.width = artCanvas.height = ART_PX;
@@ -111,6 +128,7 @@ function scaledCover(src, key) {
g.drawImage(src, 0, 0, ART_PX, ART_PX);
artKey = key;
artSrc = src;
artPx = ART_PX;
return artCanvas;
}
@@ -157,6 +175,7 @@ export function createNowPlaying(ctx) {
const screen = {
id: 'nowplaying',
adaptive: true,
title: 'Now Playing',
// Only a moving playhead needs the 4 Hz refresh; paused, the screen is static (no uploads).
get refreshMs() { return player.state === 'playing' || player.seeking ? 250 : 0; },
@@ -176,6 +195,7 @@ export function createNowPlaying(ctx) {
},
handle(e) {
relayout();
const now = ctx.now();
const cur = player.current;
if (e.type === 'press' && e.part === 'select') {
@@ -216,6 +236,7 @@ export function createNowPlaying(ctx) {
},
draw(g, r, now) {
relayout();
g.fillStyle = C.bg;
g.fillRect(r.x, r.y, r.w, r.h);
const cur = player.current;
@@ -310,6 +331,7 @@ export function createNowPlaying(ctx) {
},
animating(now) {
relayout();
// One redraw when the volume overlay or a scrubber/rating mode times out, even when paused.
if (volumeUntil && drawnAt < volumeUntil && now >= volumeUntil) return true;
const modeEnd = modeAt + ANIM.npModeTimeoutMs;
+9 -6
View File
@@ -11,7 +11,6 @@ import { songList, albumSongs } from './main-menu.js';
const KEYS = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', ' ', 'DEL', 'DONE'];
const STRIP_H = 44; // letter band at the bottom of the content area
const RESULT_ROWS = 7; // 7 x 24 px rows above the band
const KEY_W = 22;
function keyLabel(k) {
@@ -26,6 +25,8 @@ export function createSearch(ctx) {
const keyFont = font(15, 600);
const smallKeyFont = font(10, 700);
// result rows above the letter band: 7 on the Video, 4 on the nano
const resultRows = () => Math.floor((M.H - M.statusH - STRIP_H) / M.rowH);
let query = '';
let keyIndex = 0;
let inResults = false;
@@ -92,6 +93,7 @@ export function createSearch(ctx) {
return {
id: 'search',
title: 'Search',
adaptive: true, // lays itself out on the current logical screen (os.js)
enter() { runSearch(); },
handle(e) {
if (e.type === 'scroll') {
@@ -101,7 +103,7 @@ export function createSearch(ctx) {
if (next >= results.length) return false;
index = next;
if (index < first) first = index;
if (index >= first + RESULT_ROWS) first = index - RESULT_ROWS + 1;
if (index >= first + resultRows()) first = index - resultRows() + 1;
return true;
}
const next = keyIndex + e.delta;
@@ -127,10 +129,11 @@ export function createSearch(ctx) {
draw(g, r, now) {
g.fillStyle = C.bg;
g.fillRect(r.x, r.y, r.w, r.h);
const listH = RESULT_ROWS * M.rowH;
const scroll = results.length > RESULT_ROWS;
const rows = resultRows();
const listH = rows * M.rowH;
const scroll = results.length > rows;
const w = scroll ? M.W - M.scrollbarW : M.W;
for (let i = first; i < Math.min(results.length, first + RESULT_ROWS); i++) {
for (let i = first; i < Math.min(results.length, first + rows); i++) {
const res = results[i];
row.label = res.label;
row.detail = res.kind;
@@ -145,7 +148,7 @@ export function createSearch(ctx) {
g.fillText('No results', M.W / 2, r.y + 60);
g.restore();
}
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, listH, results.length, RESULT_ROWS, first);
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, listH, results.length, rows, first);
drawStrip(g, r.y + r.h - STRIP_H);
},
highlight() {
+7 -4
View File
@@ -1,7 +1,9 @@
// Settings, following the 5G: values cycle in place (Shuffle, Repeat, Clicker),
// option lists show a checkmark and return to Settings when one is picked (Backlight Timer, EQ),
// Brightness has its own slider screen, plus About, Date & Time, Legal and Reset Settings.
// EQ is VLC's own equalizer presets. (The body's colour is on the main menu: Color.)
// EQ is VLC's own equalizer presets. (The body and its colour are on the main menu: assPod Model, Color.)
import { currentModel } from '../../models.js';
// ---------------------------------------------------------------- option tables
@@ -132,22 +134,23 @@ function aboutScreen(ctx) {
const videos = lib.videos?.length ?? 0;
const albums = lib.albums?.length ?? 0;
const secs = (list) => (list ?? []).reduce((s, t) => s + (t.duration || 0), 0);
const hours = (secs(lib.tracks) + secs(lib.videos)) / 3600;
const hours = (secs(lib.tracks) + (currentModel(ctx.settings).videos ? secs(lib.videos) : 0)) / 3600;
const info = lib.info || {};
const lines = [
['Songs', songs.toLocaleString('en-US')],
['Albums', albums.toLocaleString('en-US')],
['Videos', videos.toLocaleString('en-US')],
...(currentModel(ctx.settings).videos ? [['Videos', videos.toLocaleString('en-US')]] : []),
['Playing Time', `${hours.toFixed(1)} h`],
['Sources', String((info.sources || []).length)],
['Player', 'VLC'],
['Model', 'assPod Video'],
['Model', currentModel(ctx.settings).about],
];
const total = lines.length + 1; // + the name line
let first = 0;
return {
id: 'about',
title: 'About',
adaptive: true,
handle(e) {
if (e.type === 'scroll') {
const next = Math.max(0, Math.min(total - M.rows, first + e.delta));
+12 -1
View File
@@ -19,7 +19,7 @@ export const FONTS = {
};
export const M = {
W: SCREEN_W, H: SCREEN_H,
W: SCREEN_W, H: SCREEN_H, // the logical screen: setScreenSize() switches it (the nano's is smaller)
statusH: 22, // includes the 1px bottom border; content starts at y=22
rowH: 24, rows: 9, // list area y=22..238 (2px white margin at the bottom)
padX: 8, // label left inset
@@ -28,6 +28,17 @@ export const M = {
scrollbarW: 8, // at x=312..320 when a list overflows; rows then end at 312
};
/**
* The logical screen, in the theme's own px: the Video's 320 x 240, or the nano's 240 x 180
* (models.js). Text and rows keep their size, so on the nano they fill more of the screen (six rows,
* as on the real one); the list's row count follows.
*/
export function setScreenSize(w, h) {
M.W = w;
M.H = h;
M.rows = Math.floor((h - M.statusH - 2) / M.rowH);
}
export const C = {
bg: '#ffffff', text: '#000000', textHi: '#ffffff', textDim: '#6b6b6b',
status: [[0, '#fdfdfd'], [0.5, '#e4e4e4'], [1, '#c3c3c3']],
+3 -3
View File
@@ -171,7 +171,7 @@ function drawBattery(g, x, y) {
g.lineWidth = 1;
roundRectPath(g, 0.5, 0.5, 23, 10, 1.5);
g.stroke();
// Terminal nub, 2x5 at x = 311.
// Terminal nub, 2x5 at the body's right.
g.fillStyle = C.batteryBorder;
g.fillRect(24, 3, 2, 5);
g.restore();
@@ -189,7 +189,7 @@ export function drawStatusBar(g, s) {
g.fillStyle = C.statusLine;
g.fillRect(0, M.statusH - 1, M.W, 1);
const title = ellipsize(g, s.title ?? '', FONTS.title, 220);
const title = ellipsize(g, s.title ?? '', FONTS.title, M.W - 100);
g.font = FONTS.title;
g.textAlign = 'center';
g.textBaseline = 'alphabetic';
@@ -200,7 +200,7 @@ export function drawStatusBar(g, s) {
if (s.play === 'playing' || s.play === 'paused') drawPlayIcon(g, 8, ICON_Y, s.play);
if (s.locked) drawLock(g, 22, 5);
drawBattery(g, 287, ICON_Y);
drawBattery(g, M.W - 33, ICON_Y);
g.restore();
}
+6 -2
View File
@@ -1,5 +1,7 @@
// The assPod's settings, for this session only: kept in memory in SteamVR's page (never written to
// disk, like the rest of LiquidAss), so a SteamVR restart starts from the defaults.
// disk, like the rest of LiquidAss), so a SteamVR restart starts from the defaults. The exception, by
// request: the body and its colours (model, finish, nanoFinish), which the plugin keeps across
// launches (device/shell_ext/asspod.py PREFS) and hands back at each open.
import { Emitter } from './util/emitter.js';
export const DEFAULTS = Object.freeze({
@@ -11,7 +13,9 @@ export const DEFAULTS = Object.freeze({
clicker: true,
volume: 0.6, // 0..1
timeFormat: '12', // '12' | '24'
finish: 'black', // 'black' | 'white': the body
model: 'video', // 'video' | 'nano': the body (models.js)
finish: 'black', // the Video's colour: 'black' | 'white'
nanoFinish: 'silver', // the nano's: 'silver' | 'green' | 'blue' | 'pink' | 'black' | 'red'
ratings: {}, // trackId -> 0..5 (always replace the object, never mutate it)
plays: {}, // trackId -> [playCount, lastPlayed]
});
+50 -26
View File
@@ -1,5 +1,6 @@
// The assPod's nodes in SteamVR's scene graph (systemui page): the assPod in one hand (the original
// 3D model as SteamVR render models, device/asspod/model, with the live screen on a panel), and
// The assPod's nodes in SteamVR's scene graph (systemui page): the assPod in one hand (its 3D model,
// the Video or the nano, as SteamVR render models from device/asspod/model, or drawn by podd, with
// the live screen on a panel), and
// while it is out an invisible laser blocker where the menu was, with "(menu glyph) to close".
//
// vsg-transform parent-path=/user/hand/<side> the controller's pose
@@ -26,12 +27,12 @@ const MAIN = 'system.standalone::valve.steam.gamepadui.main';
// The sheet, in the window's frame units (the window is 2.67 x 1.5 above its bottom centre; the bar
// hangs below it): from under the bar to just over the top, a little wider than the window.
const SHEET = { w: 3.2, h: 2.25, y: 0.55, z: 0.06 }; // the blocker
const MODEL_W = 0.0618; // m: the model's own width (tools/asspod-model, GEOMETRY.BODY)
// The Video's geometry (model metres, tools/asspod-model GEOMETRY), the default when show() gets none.
const VIDEO_GEO = { w: 0.0618, d: 0.0110, lcdCy: 0.0242, lcdW: 0.0508, winW: 0.0532, winH: 0.0405, wheelCy: -0.0240 };
const VIDEO_SCALE = 0.085 / 0.0618; // as shown (main.js FACE_WIDTH_M): DEFAULT_HOLD is for this body
const OVERLAY_DZ = 0.00025; // m (model units) in front of the plate: the screen panel (render-model body)
const SCREEN_RECESS = 0.0004; // m (model units) behind the plate's surface: the screen under podd's glass
const VIDEO_BELOW = 0.0001; // m (model units) behind the screen panel: podd's video picture
const LCD_M = { cy: 0.0242, w: 0.0508 }; // the LCD on the model (face.js LCD, podd.cpp LCD_CY)
const WIN_M = { w: 0.0532, h: 0.0405 }; // its window (face.js WINDOW, podd.cpp WIN)
const CAPTION = { w: 1.0, y: 0.75, z: 0.075 };
// The body in the hand (controller frame: x right, y up, -z forward): its rotation (w x y z) and
// centre for the right hand, as placed with Reposition; the left hand mirrors it (defaultHold).
@@ -196,24 +197,22 @@ export function createSceneGraph() {
// stereoscopy: the left half of its rect to the left eye). podd renders each eye's view of the
// model through each face; the faces write no depth, so the screen inside shows through podd's
// glass. The faces match podd's (native/podd/podd.cpp makeLayout): centre, right, up, normal.
function poddBody(parent, b, rects, pd, video) {
const k = b.w / MODEL_W;
// The screen panel is the window only, centred on it like the video panel: SteamVR orders
// see-through panels by their centres, so two panels on one axis keep their order from any angle
// (offset centres swapped it head on, and the screen hid the video).
const ppm = rects.face.w / b.w;
const ww = WIN_M.w * k * ppm, wh = WIN_M.h * k * ppm;
const win = { x: rects.face.x + rects.face.w / 2 - ww / 2, y: rects.face.y + rects.face.h / 2 - LCD_M.cy * k * ppm - wh / 2, w: ww, h: wh };
function poddBody(parent, b, rects, pd, video, geo) {
const k = b.w / geo.w;
// The screen panel is the window only (rects.win: where face.js draws it), centred on it like the
// video panel: SteamVR orders see-through panels by their centres, so two panels on one axis keep
// their order from any angle (offset centres swapped it head on, and the screen hid the video).
const win = rects.win;
if (video && Array.isArray(pd.video)) {
// the video's picture (podd's video tile, the frame twice: one per eye) just under the screen,
// which is clear over it but for the overlays; first, so the screen blends over it
const [vx, vy, vw, vh] = pd.video;
const pic = vxf([0, LCD_M.cy * k, b.d / 2 - (SCREEN_RECESS + VIDEO_BELOW) * k]);
const pic = vxf([0, geo.lcdCy * k, b.d / 2 - (SCREEN_RECESS + VIDEO_BELOW) * k]);
pic.appendChild(vnode('panel', {
key: pd.key,
uv_min: [r6(vx / pd.texW), r6(vy / pd.texH)],
uv_max: [r6((vx + 2 * vw) / pd.texW), r6((vy + vh) / pd.texH)],
'meters-per-pixel': r6(LCD_M.w * k / vw),
'meters-per-pixel': r6(geo.lcdW * k / vw),
origin: [0, 0],
interactive: false,
visibility: 0,
@@ -224,8 +223,8 @@ export function createSceneGraph() {
parent.appendChild(pic);
}
// recessed in the plate's opening, under podd's glass (which sits on the plate's surface)
const front = vxf([0, LCD_M.cy * k, b.d / 2 - SCREEN_RECESS * k]);
front.appendChild(pagePanel(win, b.w / rects.face.w, 'lgs-asspod-screen'));
const front = vxf([0, geo.lcdCy * k, b.d / 2 - SCREEN_RECESS * k]);
front.appendChild(pagePanel(win, geo.winW * k / win.w, 'lgs-asspod-screen'));
parent.appendChild(front);
const [hx, hy, hz] = pd.box;
const faces = {
@@ -262,8 +261,23 @@ export function createSceneGraph() {
// The body without podd: the model's parts as SteamVR render models (fixed lighting), the screen
// panel just in front of the plate.
function body(parent, b, rects, modelDir, finish) {
const k = b.w / MODEL_W;
function body(parent, b, rects, modelDir, finish, model, geo) {
const k = b.w / geo.w;
if (model === 'nano') {
// the nano's parts: its colour's aluminum, the shared caps, ports and well; the screen panel
// over its window
const scaled = vxf([0, 0, 0]);
scaled.setAttribute('scale', [k, k, k].map(r6).join(' '));
const wheel = finish === 'black' ? 'black' : 'white';
const parts = [`nano_shell_${finish}`, `nano_back_${finish}`, `nano_center_${finish}`, `nano_wheel_${wheel}`,
'nano_cap_top', 'nano_cap_bottom', 'nano_ports', 'nano_well'];
for (const name of parts) scaled.appendChild(vnode('rendermodel', { source: `${modelDir}/${name}/${name}.obj` }));
parent.appendChild(scaled);
const front = vxf([0, geo.lcdCy * k, b.d / 2 + OVERLAY_DZ * k]);
front.appendChild(pagePanel(rects.win, geo.winW * k / rects.win.w, 'lgs-asspod-screen'));
parent.appendChild(front);
return;
}
const fin = finish === 'white' ? 'white' : 'black';
const scaled = vxf([0, 0, 0]);
scaled.setAttribute('scale', [k, k, k].map(r6).join(' '));
@@ -281,15 +295,24 @@ export function createSceneGraph() {
// The default place in the hand: where you put it with Reposition (2026-10-09, right hand), and
// its mirror image across the controller's centre line for the left hand.
function defaultHold(side) {
// Another body keeps its wheel where the Video's is (over the stick), front faces level.
function defaultHold(side, b, geo) {
const r = DEFAULT_HOLD;
if (side === 'right') return { rot: r.rot.slice(), centre: r.centre.slice() };
return { rot: [r.rot[0], r.rot[1], -r.rot[2], -r.rot[3]], centre: [-r.centre[0], r.centre[1], r.centre[2]] };
const h = side === 'right'
? { rot: r.rot.slice(), centre: r.centre.slice() }
: { rot: [r.rot[0], r.rot[1], -r.rot[2], -r.rot[3]], centre: [-r.centre[0], r.centre[1], r.centre[2]] };
const k = b.w / geo.w;
const wheelV = [0, VIDEO_GEO.wheelCy * VIDEO_SCALE, VIDEO_GEO.d * VIDEO_SCALE / 2];
const wheelM = [0, geo.wheelCy * k, b.d / 2];
const d = qrot(h.rot, wheelV.map((v, i) => v - wheelM[i]));
h.centre = h.centre.map((v, i) => v + d[i]);
return h;
}
/**
* Shows the assPod in `hand` ('left' | 'right') with the sheet over the menu.
* @param {{ hand: string, body: {w,h,d,r}, rects: {face, back, dim, text}, modelDir: string, finish: string,
* @param {{ hand: string, body: {w,h,d,r}, rects: {face, back, win, dim, text}, modelDir: string, finish: string,
* model?: 'video'|'nano', geo?: {w, d, lcdCy, lcdW, winW, winH, wheelCy} (model metres),
* podd?: { key, texW, texH, box, tiles }, hold?: { rot, centre }, world?: { rot, centre } }} o
* podd: its layout (podd-out.json) when podd draws the body; hold: a place in the hand other than
* the default; world: pinned in the room (standing space) instead of on the hand
@@ -300,14 +323,15 @@ export function createSceneGraph() {
if (!attach()) return null;
clearNodes();
const side = o.hand === 'left' ? 'left' : 'right';
const { rot, centre } = o.hold && o.hold.rot && o.hold.centre ? o.hold : defaultHold(side);
const geo = o.geo || VIDEO_GEO;
const { rot, centre } = o.hold && o.hold.rot && o.hold.centre ? o.hold : defaultHold(side, o.body, geo);
// pinned in the room (Reposition: the controller moves, the assPod stays), else on the hand
const hand = o.world
? vxf(o.world.centre, o.world.rot)
: vxf([0, 0, 0], null, { 'parent-path': '/user/hand/' + side });
const hold = o.world ? vxf([0, 0, 0]) : vxf(centre, rot);
if (o.podd && o.podd.tiles && Array.isArray(o.podd.box)) poddBody(hold, o.body, o.rects, o.podd, o.video);
else body(hold, o.body, o.rects, o.modelDir, o.finish);
if (o.podd && o.podd.tiles && Array.isArray(o.podd.box)) poddBody(hold, o.body, o.rects, o.podd, o.video, geo);
else body(hold, o.body, o.rects, o.modelDir, o.finish, o.model || 'video', geo);
hand.appendChild(hold);
st.root.appendChild(hand);
// the sheet and the caption, on Steam's window
+8 -7
View File
@@ -7,9 +7,10 @@
device/vr/systemui.asspod.js), with the menu dimmed behind it.
- While it is out (the daemon's bridge value 'asspod' {open, at, ttlMs}, renewed while it stays
out): every gamepad input Steam gets is swallowed (FocusNavController's catch-all), so the
sticks and buttons drive only the assPod. A (OK, or the left controller's button in that place,
DIR_DOWN), B (CANCEL, or DIR_LEFT) and the hamburger (START) go to the daemon as asspod.btn: A is
the centre button, B the wheel's Menu, the hamburger puts the assPod away. The Steam /
sticks and buttons drive only the assPod. A (OK), B (CANCEL) and the hamburger (START) go to the
daemon as asspod.btn: A is the centre button, B the wheel's Menu, the hamburger puts the assPod
away. The d-pad directions are not relayed: Steam sends them for the left stick's tilts too, and
both sticks reach the wheel from their controller models (device/asspod/src/input). The Steam /
quick-access buttons pass. Steam's windows also take no laser clicks meanwhile
(html.lgs-asspod-out, theme/34-asspod.css). A bridge value older
than its ttl ends all of this on its own, so a dead daemon never leaves Steam's input taken.
@@ -29,10 +30,10 @@ __LGS_RT.define({
if (!R.patch.targets.dashboardBar) throw new Error('asspod: no dashboardBar target');
// Steam's gamepad buttons (EGamepadButton)
const BTN = { OK: 1, CANCEL: 2, DIR_DOWN: 10, DIR_LEFT: 11, START: 14, STEAM_GUIDE: 27, STEAM_QUICK_MENU: 28 };
// button -> [what it does, which controller]
const MAP = { [BTN.OK]: ['ok', 'right'], [BTN.CANCEL]: ['menu', 'right'], [BTN.DIR_DOWN]: ['ok', 'left'],
[BTN.DIR_LEFT]: ['menu', 'left'], [BTN.START]: ['close', 'right'] };
const BTN = { OK: 1, CANCEL: 2, START: 14, STEAM_GUIDE: 27, STEAM_QUICK_MENU: 28 };
// button -> [what it does, which controller]. No d-pad directions: tilting the left stick sends
// them too, and pressed the centre button (down) or Menu (left).
const MAP = { [BTN.OK]: ['ok', 'right'], [BTN.CANCEL]: ['menu', 'right'], [BTN.START]: ['close', 'right'] };
const OUT_CLASS = 'lgs-asspod-out';
const ICON = jsx('svg', { viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': 'true', children: [
jsx('path', { d: 'M9 3.6 20 1.4v14.1a3.4 3.4 0 1 1-2-3.1V6.3L11 7.7v10.1A3.4 3.4 0 1 1 9 14.7z' }, 'n'),
+66 -8
View File
@@ -57,6 +57,14 @@ PODD = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.real
PODD_PREBUILT = os.path.join(os.path.dirname(PODD), "prebuilt", "podd")
PODD_SPEC = "/dev/shm/lgs/podd.json"
PODD_OUT = "/dev/shm/lgs/podd-out.json"
# the bodies podd draws and their colours (device/asspod/src/models.js, native/podd/podd.cpp)
PODD_FINISHES = {"video": ("black", "white"), "nano": ("silver", "green", "blue", "pink", "black", "red")}
NANO_SCALE = 1.3125 # the nano as shown (models.js MODELS.nano.scale)
# The body you picked (assPod Model) and each body's colour, kept across launches beside the glass
# tune (lgs.TUNE): {"model": "video"|"nano", "finish": <the Video's>, "nanoFinish": <the nano's>}.
# Written only when you change them; uninstall removes it with the folder.
PREFS = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "glass-shell", "asspod.json")
PREFS_DEFAULT = {"model": "video", "finish": "black", "nanoFinish": "silver"}
API = "window.__LGS_VRX && window.__LGS_VRX.asspod && window.__LGS_VRX.asspod.api"
HOME = os.path.expanduser("~")
FLATPAK_VLC = "org.videolan.VLC"
@@ -597,14 +605,16 @@ async def _poll_loop(ctx):
s["open"], s["playing"] = bool(r.get("open")), bool(r.get("playing"))
if isinstance(r.get("holds"), dict):
s["holds"] = r["holds"] # Reposition's places, kept for the next open
_prefs_note(ctx, r.get("prefs")) # the body and colours you picked, kept across launches
# podd follows the body: on the hand (hold) or pinned in the room while repositioning (world)
if s["open"] and s.get("podd") is not None and s["podd"].returncode is None:
h, w = r.get("hold") or {}, r.get("world")
fin = r.get("finish") if r.get("finish") in ("black", "white") else "black"
want = (json.dumps(h, sort_keys=True), json.dumps(w, sort_keys=True), fin)
model = r.get("model") if r.get("model") in PODD_FINISHES else "video"
fin = r.get("finish") if r.get("finish") in PODD_FINISHES[model] else PODD_FINISHES[model][0]
want = (json.dumps(h, sort_keys=True), json.dumps(w, sort_keys=True), model, fin)
if want != s.get("podd_pose") and h.get("rot") and h.get("centre"):
s["podd_pose"] = want
_podd_spec(s, rot=h["rot"], centre=h["centre"], world=w, finish=fin)
_podd_spec(s, rot=h["rot"], centre=h["centre"], world=w, model=model, finish=fin)
if s["open"]:
s["hand"] = r.get("hand")
if was_open and not s["open"]:
@@ -1081,7 +1091,8 @@ def _model_dir():
# ------------------------------------------------------------------ podd (native/podd: the body with real shaders)
def _podd_spec(s, **kw):
spec = dict(s.get("podd_spec") or {"open": False, "hand": "right", "scale": 0.085 / 0.0618, "finish": "black"})
spec = dict(s.get("podd_spec") or {"open": False, "hand": "right", "scale": 0.085 / 0.0618, "nanoScale": NANO_SCALE,
"model": "video", "finish": "black"})
spec.update(kw)
s["podd_spec"] = spec
os.makedirs(os.path.dirname(PODD_SPEC), exist_ok=True)
@@ -1113,7 +1124,7 @@ def _podd_kill_strays():
time.sleep(0.05)
async def _podd_start(ctx, model_dir, scale, finish):
async def _podd_start(ctx, model_dir, scale, finish, nano_scale=NANO_SCALE, model="video"):
"""podd up with its layout (podd-out.json), or None (then SteamVR's render models draw the body)."""
s = _st(ctx)
if not os.access(PODD, os.X_OK) and os.path.isfile(PODD_PREBUILT):
@@ -1125,7 +1136,7 @@ async def _podd_start(ctx, model_dir, scale, finish):
_err(ctx, f"podd: {e!r}")
if not os.access(PODD, os.X_OK):
return None
_podd_spec(s, open=False, scale=scale, finish=finish)
_podd_spec(s, open=False, scale=scale, nanoScale=nano_scale, model=model, finish=finish)
p = s.get("podd")
# a podd older than its binary (a rebuild) is replaced
if p is not None and p.returncode is None and os.path.getmtime(PODD) > s.get("podd_t0", 0):
@@ -1177,6 +1188,49 @@ async def _podd_stop(ctx):
pass
# ------------------------------------------------------------------ the body you picked, kept
def _prefs_clean(p):
"""The model and colours from `p`, each checked; the defaults for anything missing or wrong."""
p = p if isinstance(p, dict) else {}
out = dict(PREFS_DEFAULT)
if p.get("model") in PODD_FINISHES:
out["model"] = p["model"]
if p.get("finish") in PODD_FINISHES["video"]:
out["finish"] = p["finish"]
if p.get("nanoFinish") in PODD_FINISHES["nano"]:
out["nanoFinish"] = p["nanoFinish"]
return out
def _prefs_load(s):
if s.get("prefs") is None:
try:
with open(PREFS, encoding="utf-8") as f:
s["prefs"] = _prefs_clean(json.load(f))
except (OSError, ValueError):
s["prefs"] = dict(PREFS_DEFAULT)
return s["prefs"]
def _prefs_note(ctx, p):
"""The page's current model and colours (each poll): saved when they changed."""
s = _st(ctx)
if not isinstance(p, dict):
return
want = _prefs_clean(p)
if want == _prefs_load(s):
return
s["prefs"] = want
try:
os.makedirs(os.path.dirname(PREFS), exist_ok=True)
with open(PREFS + ".tmp", "w", encoding="utf-8") as f:
json.dump(want, f)
os.replace(PREFS + ".tmp", PREFS)
except OSError as e:
_err(ctx, f"prefs: {e!r}")
# ------------------------------------------------------------------ actions
async def _open(ctx):
@@ -1186,8 +1240,12 @@ async def _open(ctx):
raise RuntimeError("the assPod page script is not installed in SteamVR (systemui)")
if not st.get("open"):
model_dir = _model_dir()
podd = await _podd_start(ctx, model_dir, st.get("bodyScale") or 0.085 / 0.0618, st.get("finish") or "black")
r = await _page(ctx, f"A.open({json.dumps({'modelDir': model_dir, 'podd': podd, 'holds': s.get('holds'), 'resume': _resume(s)})})")
prefs = _prefs_load(s)
model = prefs["model"]
podd = await _podd_start(ctx, model_dir, st.get("bodyScale") or 0.085 / 0.0618,
prefs["nanoFinish"] if model == "nano" else prefs["finish"],
st.get("nanoScale") or NANO_SCALE, model)
r = await _page(ctx, f"A.open({json.dumps({'modelDir': model_dir, 'podd': podd, 'holds': s.get('holds'), 'resume': _resume(s), 'prefs': prefs})})")
if not r or not r.get("open"):
await _podd_stop(ctx)
raise RuntimeError(f"open failed: {(r or {}).get('error')}")
+379 -92
View File
@@ -2319,6 +2319,11 @@ __e.drawEdge = drawEdge;
//
// The static body is drawn once per finish into a layer; a frame blits it, then the screen and the
// wheel state. draw() returns false when nothing changed, so the page repaints only on change.
//
// The nano (models.js) has only its screen here, in its window, filling most of the canvas: podd or
// its render models draw the rest, and the page shows just the window's rect (windowRect('nano')).
const { MODELS } = __req('models.js');
const BODY = { w: 61.8, h: 103.5, corner: 6.5 };
const LCD = { cy: 24.2, w: 50.8, h: 38.1 };
@@ -2367,6 +2372,41 @@ function createFace(canvas, screen, opts = {}) {
const lcd = { x: X(-LCD.w / 2), y: Y(LCD.cy + LCD.h / 2), w: LCD.w * k, h: LCD.h * k };
const wc = { x: X(0), y: Y(WHEEL.cy) };
// the nano's window: 90% of the canvas's width, centred
const NANO = MODELS.nano;
const nk = W * 0.9 / NANO.win.w; // px per mm
const nanoWin = { w: NANO.win.w * nk, h: NANO.win.h * nk };
nanoWin.x = (W - nanoWin.w) / 2;
nanoWin.y = (H - nanoWin.h) / 2;
const nanoLcd = { w: NANO.lcd.w * nk, h: NANO.lcd.h * nk };
nanoLcd.x = (W - nanoLcd.w) / 2;
nanoLcd.y = (H - nanoLcd.h) / 2;
/** The model's screen window in this canvas (px): what the page shows over the window. */
function windowRect(model) {
if (model === 'nano') return { ...nanoWin };
return { x: X(-WINDOW.w / 2), y: Y(LCD.cy + WINDOW.h / 2), w: WINDOW.w * k, h: WINDOW.h * k };
}
// The nano: its LCD in a black window (rounded like the nano's), the backlight and the glass.
function drawNano(s) {
roundRect(g, nanoWin.x, nanoWin.y, nanoWin.w, nanoWin.h, 0.6 * nk);
g.fillStyle = '#000';
g.fill();
g.imageSmoothingEnabled = true;
g.imageSmoothingQuality = 'high';
g.drawImage(screen, nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
const veil = 1 - Math.max(0, Math.min(1, s.backlight));
if (veil > 0.001) {
g.fillStyle = `rgba(0,0,0,${veil})`;
g.fillRect(nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
}
const refl = g.createLinearGradient(nanoLcd.x, nanoLcd.y, nanoLcd.x + nanoLcd.w, nanoLcd.y + nanoLcd.h);
refl.addColorStop(0, 'rgba(255,255,255,0.07)');
refl.addColorStop(0.5, 'rgba(255,255,255,0)');
g.fillStyle = refl;
g.fillRect(nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
}
function bodyLayer(finish) {
let c = layers.get(finish);
@@ -2519,12 +2559,13 @@ function createFace(canvas, screen, opts = {}) {
if (marker !== target) {
marker = target > marker ? Math.min(1, marker + dt / MARKER_IN) : Math.max(0, marker - dt / MARKER_OUT);
}
const key = [s.finish, Math.round(s.backlight * 100), s.pressed || '', marker > 0 ? Math.round(s.angle * 200) : -1,
const key = [s.model || 'video', s.finish, Math.round(s.backlight * 100), s.pressed || '', marker > 0 ? Math.round(s.angle * 200) : -1,
Math.round(marker * 20), s.reflVersion || 0, s.screenOnly ? 1 : 0, s.videoUnder ? 1 : 0].join('|');
if (!s.screenDirty && key === last) return false;
last = key;
const f = FINISHES[s.finish] || FINISHES.black;
g.clearRect(0, 0, W, H);
if (s.model === 'nano') { drawNano(s); return true; }
if (overlay) {
roundRect(g, X(-WINDOW.w / 2), Y(LCD.cy + WINDOW.h / 2), WINDOW.w * k, WINDOW.h * k, WINDOW.r * k);
g.fillStyle = '#000';
@@ -2567,7 +2608,7 @@ function createFace(canvas, screen, opts = {}) {
return true;
}
return { draw, get animating() { return marker > 0 && marker < 1; } };
return { draw, windowRect, get animating() { return marker > 0 && marker < 1; } };
}
/** The mirror-polished steel back with the peach engraved in it (static: drawn once). Polished
@@ -3048,7 +3089,8 @@ function createControllerReader() {
__defs['main.js'] = function (__e) {
__e.default = install;
// assPod: a 5th-generation-style music player in your hand in the SteamVR menu, playing the VLC
// media library. This is its half inside SteamVR's systemui page (installed by the daemon as the
// media library. The main menu's assPod Model swaps the body for a 2nd-generation nano (models.js):
// the same OS, scaled onto its smaller screen, and no video. This is its half inside SteamVR's systemui page (installed by the daemon as the
// page script device/vr/systemui.asspod.js, built from these sources by tools/build_asspod.py);
// the daemon's plugin device/shell_ext/asspod.py runs VLC and reads the library.
//
@@ -3061,8 +3103,9 @@ __e.default = install;
// Controls: both sticks turn the click wheel; a stick click presses the wheel button under it
// (centred: the centre button). A is the centre button, B is the wheel's Menu, and the hamburger
// (menu) button puts the assPod away; so does closing the SteamVR menu. The right controller's
// buttons are read here from its model; the left one's (A and B in the same places) come from Steam
// through the plugin (btn()). Music keeps playing: the player keeps running here at a few Hz, and
// buttons are read here from its model, Steam's A / B / hamburger come through the plugin (btn())
// otherwise. Both sticks act alike: only their models are read (Steam's d-pad directions, which the
// left stick's tilts also send, are not relayed). Music keeps playing: the player keeps running here at a few Hz, and
// the plugin keeps VLC up while anything plays and stops it when the assPod is away and nothing plays.
//
// The plugin talks to this page only through the returned api: poll() drains the commands for VLC
@@ -3082,6 +3125,7 @@ const { createFace, createBack, BODY } = __req('face.js');
const { drawPeachSilhouette } = __req('brand/peach.js');
const { createReflector, pose, qmul, qrot, qconj } = __req('reflect.js');
const { createSceneGraph } = __req('sg.js');
const { MODELS, currentModel, currentFinish, bodyMetres } = __req('models.js');
const FACE_W = 476; // px; the body is 61.8 x 103.5 mm
const FACE_H = Math.round(FACE_W * BODY.h / BODY.w);
@@ -3089,6 +3133,10 @@ const FACE_WIDTH_M = 0.085; // shown about 1.4x the real body, fo
const BODY_M = { // the 3D body in metres, at that scale
w: FACE_WIDTH_M, h: FACE_WIDTH_M * BODY.h / BODY.w, d: FACE_WIDTH_M * 11 / BODY.w, r: FACE_WIDTH_M * BODY.corner / BODY.w,
};
const NANO_M = bodyMetres(MODELS.nano); // the nano, at its own scale
// a model's geometry for the scene graph (model metres)
const geoOf = (m) => ({ w: m.body.w / 1000, d: m.body.d / 1000, lcdCy: m.lcd.cy / 1000, lcdW: m.lcd.w / 1000,
winW: m.win.w / 1000, winH: m.win.h / 1000, wheelCy: m.wheel.cy / 1000 });
const BACK_W = 238; // px: the steel back's live reflection (half the face's resolution)
const BACK_H = Math.round(BACK_W * BODY.h / BODY.w);
const REFLECT_EVERY = 2; // frames between reflection updates (about 30 a second)
@@ -3200,9 +3248,15 @@ function install(ctx) {
library.onLoad(() => { art.clear(); os.ctx.popToRoot(); os.invalidate(); });
// the EQ follows the setting (VLC's preset by name)
settings.on('change', ({ key }) => { if (key === 'eq' || key === '*') send({ op: 'eq', name: settings.get('eq') }); });
// the body's colour: podd follows the finish in the poll; the render-model body is rebuilt
// The body: podd follows the model and its finish in the poll (one layout holds both); the scene
// graph is rebuilt for the other body's screen panel and place in the hand, or for the render
// models' colour. (Picking the nano stops a video: os/screens/main-menu.js.)
settings.on('change', ({ key }) => {
if ((key === 'finish' || key === '*') && st.open && !st.podd) st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
if (!st.open) return;
if (key === 'model' || key === '*' || (!st.podd && (key === 'finish' || key === 'nanoFinish'))) {
if (key === 'model' && st.world) unpin(false);
st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
}
});
// ---------------------------------------------------------------- state
@@ -3295,11 +3349,13 @@ function install(ctx) {
videoMode();
const screenDirty = os.render(t);
// the painted reflections only without podd (podd shades the glass and the steel itself)
if (!st.podd && st.frames % REFLECT_EVERY === 0 && reflector.update(st.hand, st.holdPose)) back.draw(reflector.back, reflector.version);
const model = currentModel(settings).id;
const painted = !st.podd && model === 'video'; // the nano's render models carry their own back
if (painted && st.frames % REFLECT_EVERY === 0 && reflector.update(st.hand, st.holdPose)) back.draw(reflector.back, reflector.version);
const ws = wheel.state;
face.draw({
reflection: !st.podd && reflector.version ? reflector.front : null, reflVersion: reflector.version, screenOnly: !!st.podd, videoUnder: !!st.videoMode,
finish: settings.get('finish'), backlight: os.backlight, screenDirty,
reflection: painted && reflector.version ? reflector.front : null, reflVersion: reflector.version, screenOnly: !!st.podd, videoUnder: !!st.videoMode,
model, finish: currentFinish(settings), backlight: os.backlight, screenDirty,
touching: ws.touching, angle: ws.angle, pressed: ws.pressedPart || (st.keys.select ? 'select' : st.keys.menu ? 'menu' : null), now: t,
});
} catch (err) { note(err); }
@@ -3317,9 +3373,15 @@ function install(ctx) {
if (player.state === 'playing') st.bgTimer = setTimeout(background, BACKGROUND_MS);
}
// Reposition's places are kept per body: the Video's under the hand's name, the nano's as nano-<hand>
const holdKey = (hand) => (currentModel(settings).id === 'video' ? hand : `${currentModel(settings).id}-${hand}`);
function showOpts(hand, world) {
return { hand, body: BODY_M, rects, modelDir: st.modelDir, finish: settings.get('finish'), podd: st.podd,
hold: st.holds[hand] || null, world: world || null, video: !!st.videoMode };
const m = currentModel(settings);
const wr = face.windowRect(m.id);
rects.win = { x: rects.face.x + wr.x, y: rects.face.y + wr.y, w: wr.w, h: wr.h };
return { hand, body: m.id === 'video' ? BODY_M : NANO_M, rects, modelDir: st.modelDir, model: m.id, geo: geoOf(m),
finish: currentFinish(settings), podd: st.podd, hold: st.holds[holdKey(hand)] || null, world: world || null, video: !!st.videoMode };
}
// A video on the screen with podd: podd shows its picture (the daemon's frames, full rate) in a panel
@@ -3351,7 +3413,7 @@ function install(ctx) {
const hp = pose('/user/hand/' + st.hand);
if (hp) {
const inv = qconj(hp.q);
st.holds[st.hand] = {
st.holds[holdKey(st.hand)] = {
rot: qmul(inv, st.world.rot),
centre: qrot(inv, st.world.centre.map((v, i) => v - hp.t[i])),
};
@@ -3366,6 +3428,7 @@ function install(ctx) {
return {
id: 'reposition',
title: 'Reposition',
adaptive: true,
keepAwake: true,
enter() { if (!pin()) note('reposition: no hand pose'); },
exit() { if (st.world) unpin(false); },
@@ -3374,7 +3437,7 @@ function install(ctx) {
if (e.type !== 'press') return e.type === 'release' ? false : undefined;
if (e.part === 'select') return done(() => unpin(true));
if (e.part === 'menu') return done(() => unpin(false));
if (e.part === 'play') return done(() => { st.holds[st.hand] = null; unpin(false); });
if (e.part === 'play') return done(() => { st.holds[holdKey(st.hand)] = null; unpin(false); });
return false;
},
draw(g, r) {
@@ -3428,6 +3491,10 @@ function install(ctx) {
for (const [p, t] of Object.entries(opts.resume)) if (Number.isFinite(t)) resume.set(p, t);
}
if (opts.holds && typeof opts.holds === 'object') st.holds = Object.assign({ left: null, right: null }, opts.holds);
// the body and colours picked last time (the plugin keeps them: device/shell_ext/asspod.py PREFS)
if (opts.prefs && typeof opts.prefs === 'object') {
for (const key of ['model', 'finish', 'nanoFinish']) if (typeof opts.prefs[key] === 'string') settings.set(key, opts.prefs[key]);
}
st.world = null;
st.videoMode = false;
art.videoExternal = false;
@@ -3485,7 +3552,8 @@ function install(ctx) {
function status() {
return {
ok: true, open: st.open, hand: st.hand, closedBy: st.closedBy, loading: st.loading,
hold: st.holdPose || null, bodyScale: BODY_M.w / 0.0618, finish: settings.get('finish'), podd: !!st.podd,
hold: st.holdPose || null, bodyScale: BODY_M.w / 0.0618, nanoScale: MODELS.nano.scale, model: currentModel(settings).id,
finish: currentFinish(settings), podd: !!st.podd,
player: { state: player.state, current: player.current ? { id: player.current.id, title: player.current.title } : null,
position: Math.round((player.position || 0) * 10) / 10, queue: player.queue.length, volume: player.volume },
library: { tracks: library.tracks.length, albums: library.albums.length, videos: library.videos.length },
@@ -3501,6 +3569,7 @@ function install(ctx) {
os.ctx.push({
id: 'notice',
title: String(title || 'assPod'),
adaptive: true,
handle(e) {
if (e.type === 'press' && (e.part === 'select' || e.part === 'menu')) { os.ctx.pop(); return true; }
return e.type === 'scroll' ? false : undefined;
@@ -3542,7 +3611,8 @@ function install(ctx) {
playing: player.state === 'playing', state: player.state,
current: player.current ? player.current.path : null,
volume: player.volume, eq: settings.get('eq'),
hold: st.holdPose || null, world: st.world, holds: st.holds, finish: settings.get('finish'),
hold: st.holdPose || null, world: st.world, holds: st.holds, model: currentModel(settings).id, finish: currentFinish(settings),
prefs: { model: currentModel(settings).id, finish: settings.get('finish'), nanoFinish: settings.get('nanoFinish') },
position: Math.round((player.position || 0) * 1000) / 1000,
video: player.current && player.current.kind === 'video' && os.ctx.top()?.id === 'videoplayer' && st.open,
};
@@ -4398,6 +4468,75 @@ class Player extends Emitter {
this.#settings.set('plays', { ...plays, [item.id]: [item.playCount ?? 0, item.lastPlayed] });
}
}
};
// ---------------------------------------------------------------- models.js
__defs['models.js'] = function (__e) {
__e.currentModel = currentModel;
__e.currentFinish = currentFinish;
__e.bodyMetres = bodyMetres;
// The assPod's bodies: the Video (5th generation, the original) and the nano (2nd generation). The
// main menu's assPod Model picks one; Color picks among that body's own finishes. Both run the same
// OS on a 4:3 screen; the nano's is 240 x 180 logical px against the Video's 320 x 240, so its text is
// larger on its screen, as on the real one. The nano plays no video: its menus leave the Videos out.
//
// Geometry in millimetres, model frame (tools/asspod-model, tools/nano2-model GEOMETRY): origin at
// the body's centre, y toward the top. `scale` is how much larger than life it is shown in the hand;
// podd (native/podd/podd.cpp VIDEO / NANO) and its render models use the same numbers. `screen` is
// the OS's logical screen on that body.
const MODELS = Object.freeze({
video: Object.freeze({
id: 'video',
label: 'Video Gen 1',
about: 'assPod Video',
body: { w: 61.8, h: 103.5, d: 11.0 },
lcd: { cy: 24.2, w: 50.8, h: 38.1 },
win: { w: 53.2, h: 40.5 },
wheel: { cy: -24.0 },
screen: { w: 320, h: 240 }, // logical px (os/theme.js setScreenSize)
scale: 0.085 / 0.0618, // shown 85 mm wide
finishKey: 'finish',
finishes: [['black', 'Black'], ['white', 'White']],
videos: true,
}),
nano: Object.freeze({
id: 'nano',
label: 'Nano Gen 2',
about: 'assPod nano',
body: { w: 40.0, h: 90.0, d: 6.5 },
lcd: { cy: 28.88, w: 29.6, h: 22.2 },
win: { w: 31.62, h: 24.04 },
wheel: { cy: -13.3 },
screen: { w: 240, h: 180 }, // the same text on less screen: six rows, as on the real nano
scale: 1.3125, // shown 52.5 mm wide
finishKey: 'nanoFinish',
finishes: [['silver', 'Silver'], ['green', 'Green'], ['blue', 'Blue'], ['pink', 'Pink'], ['black', 'Black'], ['red', '(PRODUCT)RED']],
videos: false,
}),
});
const MODEL_ORDER = ['video', 'nano'];
/** The current body's definition. */
function currentModel(settings) {
return MODELS[settings.get('model')] || MODELS.video;
}
/** The current body's finish (one of its own; its first otherwise). */
function currentFinish(settings) {
const m = currentModel(settings);
const f = settings.get(m.finishKey);
return m.finishes.some(([k]) => k === f) ? f : m.finishes[0][0];
}
/** The body in metres as shown in the hand: { w, h, d } at the model's scale. */
function bodyMetres(m) {
const k = m.scale / 1000;
return { w: m.body.w * k, h: m.body.h * k, d: m.body.d * k };
}
__e.MODELS = MODELS;
__e.MODEL_ORDER = MODEL_ORDER;
};
// ---------------------------------------------------------------- os/menu.js
__defs['os/menu.js'] = function (__e) {
@@ -4470,7 +4609,11 @@ function createMenu(ctx, def) {
}
function refresh() {
// the highlight stays on its item when the list changes around it (the main menu without Videos)
const was = items[index];
items = resolve(def.items) ?? [];
const j = was ? items.indexOf(was) : -1;
if (j >= 0) index = j;
clampWindow();
}
@@ -4584,9 +4727,11 @@ function createMenu(ctx, def) {
__e.createOS = createOS;
// assPod OS core: screen stack, 220 ms slide transitions, status bar, backlight timer and dimming,
// sleep/wake, the boot logo, the default button behavior, and render throttling for the screen
// texture. Draws the 320x240 logical LCD onto the 640x480 canvas that main.js uploads as a texture.
// texture. Draws the logical LCD (320x240 on the Video, 240x180 on the nano) onto the 640x480 canvas
// that main.js uploads as a texture.
const { SCREEN_W } = __req('config.js');
const { currentModel } = __req('models.js');
const { settings: defaultSettings } = __req('settings.js');
const { drawPeachSilhouette } = __req('brand/peach.js');
const theme = __req('os/theme.js');
@@ -4595,7 +4740,7 @@ const { createMenu } = __req('os/menu.js');
const { createMainMenu } = __req('os/screens/main-menu.js');
const { createNowPlaying } = __req('os/screens/now-playing.js');
const { M, C, ANIM } = theme;
const { M, C, ANIM, setScreenSize } = theme;
/**
* @typedef {import('../input/clickwheel.js').WheelEvent} WheelEvent
@@ -4628,8 +4773,9 @@ const { M, C, ANIM } = theme;
// ---------------------------------------------------------------- constants
const CONTENT = Object.freeze({ x: 0, y: M.statusH, w: M.W, h: M.H - M.statusH });
const FULL = Object.freeze({ x: 0, y: 0, w: M.W, h: M.H });
// the content area under the status bar, and the whole screen (the logical size can change: models.js)
const content = () => ({ x: 0, y: M.statusH, w: M.W, h: M.H - M.statusH });
const full = () => ({ x: 0, y: 0, w: M.W, h: M.H });
const FRAME_MS = 1000 / ANIM.maxFps;
// rAF timestamps jitter by a fraction of a ms; without slack a 25 ms cap would skip a frame at 80 Hz.
const FRAME_SLACK_MS = 1;
@@ -4658,7 +4804,12 @@ function normKey(s) {
*/
function createOS({ canvas, player, library, art, apps, settings = defaultSettings, boot: bootEnabled = false }) {
const g = canvas.getContext('2d');
const scale = canvas.width / SCREEN_W;
// The body's logical screen (models.js): the canvas stays the same size and the scale follows.
const applyModel = () => { const m = currentModel(settings); setScreenSize(m.screen.w, m.screen.h); };
applyModel();
if (typeof settings.on === 'function') {
settings.on('change', ({ key }) => { if (key === 'model' || key === '*') { applyModel(); dirty = DIRTY_NOW; } });
}
/** @type {Screen[]} */
const stack = [];
@@ -4944,8 +5095,18 @@ function createOS({ canvas, player, library, art, apps, settings = defaultSettin
}
const reported = new WeakSet();
// Menus and the screens marked `adaptive` lay themselves out on the current logical screen; the
// rest (the games, the clock, Cover Flow...) are drawn for the Video's 320 x 240 and scaled to fit.
function drawScreen(s, r, now) {
try {
if (!s.isMenu && !s.adaptive && M.W !== SCREEN_W) {
const k = M.W / SCREEN_W;
g.save();
g.scale(k, k);
s.draw(g, { x: r.x / k, y: r.y / k, w: r.w / k, h: r.h / k }, now);
g.restore();
return;
}
s.draw(g, r, now);
} catch (err) {
// One broken screen must not take the whole OS down every frame; report it once.
@@ -4957,12 +5118,13 @@ function createOS({ canvas, player, library, art, apps, settings = defaultSettin
if (dx <= -M.W || dx >= M.W) return;
g.save();
g.beginPath();
g.rect(CONTENT.x, CONTENT.y, CONTENT.w, CONTENT.h);
const r = content();
g.rect(r.x, r.y, r.w, r.h);
g.clip();
g.translate(dx, 0);
g.fillStyle = C.bg;
g.fillRect(CONTENT.x, CONTENT.y, CONTENT.w, CONTENT.h);
drawScreen(s, CONTENT, now);
g.fillRect(r.x, r.y, r.w, r.h);
drawScreen(s, r, now);
g.restore();
}
@@ -4981,7 +5143,7 @@ function createOS({ canvas, player, library, art, apps, settings = defaultSettin
g.fillStyle = '#000000';
g.fillRect(0, 0, M.W, M.H);
}
drawScreen(s, FULL, now);
drawScreen(s, full(), now);
g.restore();
return;
}
@@ -4993,6 +5155,7 @@ function createOS({ canvas, player, library, art, apps, settings = defaultSettin
}
function paint(now) {
const scale = canvas.width / M.W;
g.setTransform(scale, 0, 0, scale, 0, 0);
g.globalAlpha = 1;
g.globalCompositeOperation = 'source-over';
@@ -5226,6 +5389,7 @@ __e.createMainMenu = createMainMenu;
const { createRng, hashString } = __req('util/rng.js');
const { createSettings } = __req('os/screens/settings.js');
const { createSearch } = __req('os/screens/search.js');
const { MODELS, MODEL_ORDER, currentModel, currentFinish } = __req('models.js');
// ---------------------------------------------------------------- sorting
@@ -5254,7 +5418,9 @@ function albumOrder(library) {
* A list of songs (or videos, in playlists). Selecting row i plays the whole list from i,
* the way the assPod queues whatever list you picked from, then opens Now Playing.
*/
function songList(ctx, id, title, list) {
function songList(ctx, id, title, all) {
// the nano plays no video: a playlist's videos are left out on it
const list = currentModel(ctx.settings).videos ? all : all.filter((t) => t.kind !== 'video');
const items = list.map((t, i) => ({
label: t.title,
speaker: () => ctx.player.current?.id === t.id && ctx.player.state !== 'stopped',
@@ -5392,31 +5558,84 @@ function shuffleSongs(ctx) {
return true;
}
// ---------------------------------------------------------------- the body: model and colour
/** assPod Model: the Video or the nano, checkmarked; picking one switches the body and goes back.
* The nano plays no video: a video playing stops when it is picked. */
function modelMenu(ctx) {
return ctx.menu({
id: 'model',
title: 'assPod Model',
initialIndex: () => Math.max(0, MODEL_ORDER.indexOf(currentModel(ctx.settings).id)),
items: MODEL_ORDER.map((id) => ({
label: MODELS[id].label,
check: () => currentModel(ctx.settings).id === id,
action: (c) => {
if (!MODELS[id].videos && c.player.current?.kind === 'video') c.player.stop();
c.settings.set('model', id);
c.pop();
return true;
},
})),
});
}
/** Color: the current body's own finishes, checkmarked. */
function colorMenu(ctx) {
const m = currentModel(ctx.settings);
return ctx.menu({
id: 'color',
title: 'Color',
initialIndex: () => Math.max(0, m.finishes.findIndex(([k]) => k === currentFinish(ctx.settings))),
items: m.finishes.map(([key, label]) => ({
label,
check: () => currentFinish(ctx.settings) === key,
action: (c) => {
c.settings.set(m.finishKey, key);
c.pop();
return true;
},
})),
});
}
// ---------------------------------------------------------------- main menu
function createMainMenu(ctx) {
const base = [
const finishLabel = () => {
const m = currentModel(ctx.settings);
const f = currentFinish(ctx.settings);
return (m.finishes.find(([k]) => k === f) || m.finishes[0])[1];
};
const all = [
{ id: 'music', label: 'Music', open: createMusicMenu },
{ id: 'videos', label: 'Videos', open: (c) => c.apps.videos(c) },
{ id: 'extras', label: 'Extras', open: (c) => c.apps.extras(c) },
{ id: 'settings', label: 'Settings', open: createSettings },
{ id: 'reposition', label: 'Reposition', open: (c) => c.apps.reposition(c) },
{
id: 'color',
label: 'Color',
detail: () => (ctx.settings.get('finish') === 'white' ? 'White' : 'Black'),
action: (c) => { c.settings.set('finish', c.settings.get('finish') === 'white' ? 'black' : 'white'); return true; },
},
{ id: 'model', label: 'assPod Model', detail: () => currentModel(ctx.settings).label, open: modelMenu },
{ id: 'color', label: 'Color', detail: finishLabel, open: colorMenu },
{ id: 'shuffle', label: 'Shuffle Songs', action: shuffleSongs },
{ id: 'backlight', label: 'Backlight', action: (c) => { c.os.toggleBacklight(); return false; } },
];
const withNowPlaying = [
...base,
{ id: 'nowplaying', label: 'Now Playing', chevron: true, action: (c) => { c.nowPlaying(); return true; } },
];
const nowPlaying = { id: 'nowplaying', label: 'Now Playing', chevron: true, action: (c) => { c.nowPlaying(); return true; } };
// the same item objects every time, so the menu keeps its place across a model switch
const lists = new Map();
return ctx.menu({
id: 'main',
title: 'assPod',
// Like the 5G, Now Playing is listed only while something is queued (not after the queue ends).
items: () => (ctx.player.current && ctx.player.state !== 'stopped' ? withNowPlaying : base),
// Like the 5G, Now Playing is listed only while something is queued (not after the queue ends);
// the nano has no Videos.
items: () => {
const videos = currentModel(ctx.settings).videos;
const playing = !!(ctx.player.current && ctx.player.state !== 'stopped');
const key = `${videos}|${playing}`;
if (!lists.has(key)) {
const base = videos ? all : all.filter((it) => it.id !== 'videos');
lists.set(key, playing ? [...base, nowPlaying] : base);
}
return lists.get(key);
},
});
}
};
@@ -5427,23 +5646,40 @@ __e.createNowPlaying = createNowPlaying;
// artist / album on the right, and a progress bar with elapsed and remaining time.
// Center click cycles progress -> scrubber -> rating; the wheel sets volume, seeks or rates.
const { SCREEN_SCALE } = __req('config.js');
const { SCREEN_W, SCREEN_SCALE } = __req('config.js');
const { M } = __req('os/theme.js');
// ---------------------------------------------------------------- layout
const POS = { x: 10, y: 40 };
const ART = { x: 10, y: 48, s: 112 };
const TEXT = { x: 132, w: 180, title: 82, artist: 102, album: 122 };
const BAR = { x: 10, y: 182, w: 300, h: 10 };
const TIME_Y = 208;
const STARS = { cx: 160, cy: 187 };
const VOLUME = { x: 10, y: 180, w: 300 };
// The Video's 320 x 240 layout, and the nano's on its 240 x 180 screen (the same text, less room:
// smaller art and a shorter bar). relayout() picks the one for the current logical screen.
const LAYOUTS = {
320: {
POS: { x: 10, y: 40 }, ART: { x: 10, y: 48, s: 112 }, TEXT: { x: 132, w: 180, title: 82, artist: 102, album: 122 },
BAR: { x: 10, y: 182, w: 300, h: 10 }, TIME_Y: 208, STARS: { cx: 160, cy: 187 }, VOLUME: { x: 10, y: 180, w: 300 },
GLYPH: { right: 310, w: 12, h: 9, gap: 5 },
},
240: {
POS: { x: 8, y: 38 }, ART: { x: 8, y: 46, s: 80 }, TEXT: { x: 96, w: 136, title: 66, artist: 84, album: 102 },
BAR: { x: 8, y: 136, w: 224, h: 9 }, TIME_Y: 160, STARS: { cx: 120, cy: 141 }, VOLUME: { x: 8, y: 134, w: 224 },
GLYPH: { right: 232, w: 12, h: 9, gap: 5 },
},
};
let POS, ART, TEXT, BAR, TIME_Y, STARS, VOLUME, GLYPH, ART_PX;
let laidOut = 0;
function relayout() {
if (laidOut === M.W) return;
laidOut = M.W;
({ POS, ART, TEXT, BAR, TIME_Y, STARS, VOLUME, GLYPH } = LAYOUTS[M.W] || LAYOUTS[320]);
// the cover is resampled to the art box's backing-store size (the canvas is 640 px wide)
ART_PX = Math.round(ART.s * SCREEN_W * SCREEN_SCALE / M.W);
}
relayout();
const VOLUME_STEPS = 16;
const SCRUB_STEP_S = 2;
const SCRUB_FAST_MS = 120; // steps closer than this count as "scrolling fast"
const MODES = ['progress', 'scrub', 'rating'];
const GLYPH = { right: 310, w: 12, h: 9, gap: 5 }; // shuffle / repeat marks, right of "n of N"
/** Grey square with a beamed note, for items without cover art. */
function drawPlaceholderArt(g, x, y, s) {
@@ -5519,14 +5755,14 @@ function drawRepeatGlyph(g, x, y, color, one, oneFont) {
// The 256px cover is resampled once per album to the art box's backing-store size (224px), so
// each 4 Hz (or 40 fps marquee) redraw is a 1:1 blit instead of a high-quality downscale.
const ART_PX = ART.s * SCREEN_SCALE;
let artKey = null;
let artPx = 0;
let artSrc = null;
let artCanvas = null;
function scaledCover(src, key) {
// The source canvas counts too: art.clear() redraws covers (late web fonts) as new canvases.
if (key === artKey && src === artSrc && artCanvas) return artCanvas;
if (key === artKey && src === artSrc && artCanvas && artPx === ART_PX) return artCanvas;
if (typeof document === 'undefined') return src;
artCanvas ??= document.createElement('canvas');
artCanvas.width = artCanvas.height = ART_PX;
@@ -5536,6 +5772,7 @@ function scaledCover(src, key) {
g.drawImage(src, 0, 0, ART_PX, ART_PX);
artKey = key;
artSrc = src;
artPx = ART_PX;
return artCanvas;
}
@@ -5582,6 +5819,7 @@ function createNowPlaying(ctx) {
const screen = {
id: 'nowplaying',
adaptive: true,
title: 'Now Playing',
// Only a moving playhead needs the 4 Hz refresh; paused, the screen is static (no uploads).
get refreshMs() { return player.state === 'playing' || player.seeking ? 250 : 0; },
@@ -5601,6 +5839,7 @@ function createNowPlaying(ctx) {
},
handle(e) {
relayout();
const now = ctx.now();
const cur = player.current;
if (e.type === 'press' && e.part === 'select') {
@@ -5641,6 +5880,7 @@ function createNowPlaying(ctx) {
},
draw(g, r, now) {
relayout();
g.fillStyle = C.bg;
g.fillRect(r.x, r.y, r.w, r.h);
const cur = player.current;
@@ -5735,6 +5975,7 @@ function createNowPlaying(ctx) {
},
animating(now) {
relayout();
// One redraw when the volume overlay or a scrubber/rating mode times out, even when paused.
if (volumeUntil && drawnAt < volumeUntil && now >= volumeUntil) return true;
const modeEnd = modeAt + ANIM.npModeTimeoutMs;
@@ -5768,7 +6009,6 @@ const { songList, albumSongs } = __req('os/screens/main-menu.js');
const KEYS = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', ' ', 'DEL', 'DONE'];
const STRIP_H = 44; // letter band at the bottom of the content area
const RESULT_ROWS = 7; // 7 x 24 px rows above the band
const KEY_W = 22;
function keyLabel(k) {
@@ -5783,6 +6023,8 @@ function createSearch(ctx) {
const keyFont = font(15, 600);
const smallKeyFont = font(10, 700);
// result rows above the letter band: 7 on the Video, 4 on the nano
const resultRows = () => Math.floor((M.H - M.statusH - STRIP_H) / M.rowH);
let query = '';
let keyIndex = 0;
let inResults = false;
@@ -5849,6 +6091,7 @@ function createSearch(ctx) {
return {
id: 'search',
title: 'Search',
adaptive: true, // lays itself out on the current logical screen (os.js)
enter() { runSearch(); },
handle(e) {
if (e.type === 'scroll') {
@@ -5858,7 +6101,7 @@ function createSearch(ctx) {
if (next >= results.length) return false;
index = next;
if (index < first) first = index;
if (index >= first + RESULT_ROWS) first = index - RESULT_ROWS + 1;
if (index >= first + resultRows()) first = index - resultRows() + 1;
return true;
}
const next = keyIndex + e.delta;
@@ -5884,10 +6127,11 @@ function createSearch(ctx) {
draw(g, r, now) {
g.fillStyle = C.bg;
g.fillRect(r.x, r.y, r.w, r.h);
const listH = RESULT_ROWS * M.rowH;
const scroll = results.length > RESULT_ROWS;
const rows = resultRows();
const listH = rows * M.rowH;
const scroll = results.length > rows;
const w = scroll ? M.W - M.scrollbarW : M.W;
for (let i = first; i < Math.min(results.length, first + RESULT_ROWS); i++) {
for (let i = first; i < Math.min(results.length, first + rows); i++) {
const res = results[i];
row.label = res.label;
row.detail = res.kind;
@@ -5902,7 +6146,7 @@ function createSearch(ctx) {
g.fillText('No results', M.W / 2, r.y + 60);
g.restore();
}
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, listH, results.length, RESULT_ROWS, first);
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, listH, results.length, rows, first);
drawStrip(g, r.y + r.h - STRIP_H);
},
highlight() {
@@ -5919,7 +6163,9 @@ __e.createSettings = createSettings;
// Settings, following the 5G: values cycle in place (Shuffle, Repeat, Clicker),
// option lists show a checkmark and return to Settings when one is picked (Backlight Timer, EQ),
// Brightness has its own slider screen, plus About, Date & Time, Legal and Reset Settings.
// EQ is VLC's own equalizer presets. (The body's colour is on the main menu: Color.)
// EQ is VLC's own equalizer presets. (The body and its colour are on the main menu: assPod Model, Color.)
const { currentModel } = __req('models.js');
// ---------------------------------------------------------------- option tables
@@ -6050,22 +6296,23 @@ function aboutScreen(ctx) {
const videos = lib.videos?.length ?? 0;
const albums = lib.albums?.length ?? 0;
const secs = (list) => (list ?? []).reduce((s, t) => s + (t.duration || 0), 0);
const hours = (secs(lib.tracks) + secs(lib.videos)) / 3600;
const hours = (secs(lib.tracks) + (currentModel(ctx.settings).videos ? secs(lib.videos) : 0)) / 3600;
const info = lib.info || {};
const lines = [
['Songs', songs.toLocaleString('en-US')],
['Albums', albums.toLocaleString('en-US')],
['Videos', videos.toLocaleString('en-US')],
...(currentModel(ctx.settings).videos ? [['Videos', videos.toLocaleString('en-US')]] : []),
['Playing Time', `${hours.toFixed(1)} h`],
['Sources', String((info.sources || []).length)],
['Player', 'VLC'],
['Model', 'assPod Video'],
['Model', currentModel(ctx.settings).about],
];
const total = lines.length + 1; // + the name line
let first = 0;
return {
id: 'about',
title: 'About',
adaptive: true,
handle(e) {
if (e.type === 'scroll') {
const next = Math.max(0, Math.min(total - M.rows, first + e.delta));
@@ -6252,6 +6499,7 @@ __e.EQ_NAMES = EQ_NAMES;
};
// ---------------------------------------------------------------- os/theme.js
__defs['os/theme.js'] = function (__e) {
__e.setScreenSize = setScreenSize;
// 5G visual spec, logical px on the 320x240 screen
const { SCREEN_W, SCREEN_H } = __req('config.js');
@@ -6273,7 +6521,7 @@ const FONTS = {
};
const M = {
W: SCREEN_W, H: SCREEN_H,
W: SCREEN_W, H: SCREEN_H, // the logical screen: setScreenSize() switches it (the nano's is smaller)
statusH: 22, // includes the 1px bottom border; content starts at y=22
rowH: 24, rows: 9, // list area y=22..238 (2px white margin at the bottom)
padX: 8, // label left inset
@@ -6282,6 +6530,17 @@ const M = {
scrollbarW: 8, // at x=312..320 when a list overflows; rows then end at 312
};
/**
* The logical screen, in the theme's own px: the Video's 320 x 240, or the nano's 240 x 180
* (models.js). Text and rows keep their size, so on the nano they fill more of the screen (six rows,
* as on the real one); the list's row count follows.
*/
function setScreenSize(w, h) {
M.W = w;
M.H = h;
M.rows = Math.floor((h - M.statusH - 2) / M.rowH);
}
const C = {
bg: '#ffffff', text: '#000000', textHi: '#ffffff', textDim: '#6b6b6b',
status: [[0, '#fdfdfd'], [0.5, '#e4e4e4'], [1, '#c3c3c3']],
@@ -6508,7 +6767,7 @@ function drawBattery(g, x, y) {
g.lineWidth = 1;
roundRectPath(g, 0.5, 0.5, 23, 10, 1.5);
g.stroke();
// Terminal nub, 2x5 at x = 311.
// Terminal nub, 2x5 at the body's right.
g.fillStyle = C.batteryBorder;
g.fillRect(24, 3, 2, 5);
g.restore();
@@ -6526,7 +6785,7 @@ function drawStatusBar(g, s) {
g.fillStyle = C.statusLine;
g.fillRect(0, M.statusH - 1, M.W, 1);
const title = ellipsize(g, s.title ?? '', FONTS.title, 220);
const title = ellipsize(g, s.title ?? '', FONTS.title, M.W - 100);
g.font = FONTS.title;
g.textAlign = 'center';
g.textBaseline = 'alphabetic';
@@ -6537,7 +6796,7 @@ function drawStatusBar(g, s) {
if (s.play === 'playing' || s.play === 'paused') drawPlayIcon(g, 8, ICON_Y, s.play);
if (s.locked) drawLock(g, 22, 5);
drawBattery(g, 287, ICON_Y);
drawBattery(g, M.W - 33, ICON_Y);
g.restore();
}
@@ -6970,7 +7229,9 @@ __e.qconj = qconj;
// ---------------------------------------------------------------- settings.js
__defs['settings.js'] = function (__e) {
// The assPod's settings, for this session only: kept in memory in SteamVR's page (never written to
// disk, like the rest of LiquidAss), so a SteamVR restart starts from the defaults.
// disk, like the rest of LiquidAss), so a SteamVR restart starts from the defaults. The exception, by
// request: the body and its colours (model, finish, nanoFinish), which the plugin keeps across
// launches (device/shell_ext/asspod.py PREFS) and hands back at each open.
const { Emitter } = __req('util/emitter.js');
const DEFAULTS = Object.freeze({
@@ -6982,7 +7243,9 @@ const DEFAULTS = Object.freeze({
clicker: true,
volume: 0.6, // 0..1
timeFormat: '12', // '12' | '24'
finish: 'black', // 'black' | 'white': the body
model: 'video', // 'video' | 'nano': the body (models.js)
finish: 'black', // the Video's colour: 'black' | 'white'
nanoFinish: 'silver', // the nano's: 'silver' | 'green' | 'blue' | 'pink' | 'black' | 'red'
ratings: {}, // trackId -> 0..5 (always replace the object, never mutate it)
plays: {}, // trackId -> [playCount, lastPlayed]
});
@@ -7011,8 +7274,9 @@ __e.settings = settings;
// ---------------------------------------------------------------- sg.js
__defs['sg.js'] = function (__e) {
__e.createSceneGraph = createSceneGraph;
// The assPod's nodes in SteamVR's scene graph (systemui page): the assPod in one hand (the original
// 3D model as SteamVR render models, device/asspod/model, with the live screen on a panel), and
// The assPod's nodes in SteamVR's scene graph (systemui page): the assPod in one hand (its 3D model,
// the Video or the nano, as SteamVR render models from device/asspod/model, or drawn by podd, with
// the live screen on a panel), and
// while it is out an invisible laser blocker where the menu was, with "(menu glyph) to close".
//
// vsg-transform parent-path=/user/hand/<side> the controller's pose
@@ -7039,12 +7303,12 @@ const MAIN = 'system.standalone::valve.steam.gamepadui.main';
// The sheet, in the window's frame units (the window is 2.67 x 1.5 above its bottom centre; the bar
// hangs below it): from under the bar to just over the top, a little wider than the window.
const SHEET = { w: 3.2, h: 2.25, y: 0.55, z: 0.06 }; // the blocker
const MODEL_W = 0.0618; // m: the model's own width (tools/asspod-model, GEOMETRY.BODY)
// The Video's geometry (model metres, tools/asspod-model GEOMETRY), the default when show() gets none.
const VIDEO_GEO = { w: 0.0618, d: 0.0110, lcdCy: 0.0242, lcdW: 0.0508, winW: 0.0532, winH: 0.0405, wheelCy: -0.0240 };
const VIDEO_SCALE = 0.085 / 0.0618; // as shown (main.js FACE_WIDTH_M): DEFAULT_HOLD is for this body
const OVERLAY_DZ = 0.00025; // m (model units) in front of the plate: the screen panel (render-model body)
const SCREEN_RECESS = 0.0004; // m (model units) behind the plate's surface: the screen under podd's glass
const VIDEO_BELOW = 0.0001; // m (model units) behind the screen panel: podd's video picture
const LCD_M = { cy: 0.0242, w: 0.0508 }; // the LCD on the model (face.js LCD, podd.cpp LCD_CY)
const WIN_M = { w: 0.0532, h: 0.0405 }; // its window (face.js WINDOW, podd.cpp WIN)
const CAPTION = { w: 1.0, y: 0.75, z: 0.075 };
// The body in the hand (controller frame: x right, y up, -z forward): its rotation (w x y z) and
// centre for the right hand, as placed with Reposition; the left hand mirrors it (defaultHold).
@@ -7209,24 +7473,22 @@ function createSceneGraph() {
// stereoscopy: the left half of its rect to the left eye). podd renders each eye's view of the
// model through each face; the faces write no depth, so the screen inside shows through podd's
// glass. The faces match podd's (native/podd/podd.cpp makeLayout): centre, right, up, normal.
function poddBody(parent, b, rects, pd, video) {
const k = b.w / MODEL_W;
// The screen panel is the window only, centred on it like the video panel: SteamVR orders
// see-through panels by their centres, so two panels on one axis keep their order from any angle
// (offset centres swapped it head on, and the screen hid the video).
const ppm = rects.face.w / b.w;
const ww = WIN_M.w * k * ppm, wh = WIN_M.h * k * ppm;
const win = { x: rects.face.x + rects.face.w / 2 - ww / 2, y: rects.face.y + rects.face.h / 2 - LCD_M.cy * k * ppm - wh / 2, w: ww, h: wh };
function poddBody(parent, b, rects, pd, video, geo) {
const k = b.w / geo.w;
// The screen panel is the window only (rects.win: where face.js draws it), centred on it like the
// video panel: SteamVR orders see-through panels by their centres, so two panels on one axis keep
// their order from any angle (offset centres swapped it head on, and the screen hid the video).
const win = rects.win;
if (video && Array.isArray(pd.video)) {
// the video's picture (podd's video tile, the frame twice: one per eye) just under the screen,
// which is clear over it but for the overlays; first, so the screen blends over it
const [vx, vy, vw, vh] = pd.video;
const pic = vxf([0, LCD_M.cy * k, b.d / 2 - (SCREEN_RECESS + VIDEO_BELOW) * k]);
const pic = vxf([0, geo.lcdCy * k, b.d / 2 - (SCREEN_RECESS + VIDEO_BELOW) * k]);
pic.appendChild(vnode('panel', {
key: pd.key,
uv_min: [r6(vx / pd.texW), r6(vy / pd.texH)],
uv_max: [r6((vx + 2 * vw) / pd.texW), r6((vy + vh) / pd.texH)],
'meters-per-pixel': r6(LCD_M.w * k / vw),
'meters-per-pixel': r6(geo.lcdW * k / vw),
origin: [0, 0],
interactive: false,
visibility: 0,
@@ -7237,8 +7499,8 @@ function createSceneGraph() {
parent.appendChild(pic);
}
// recessed in the plate's opening, under podd's glass (which sits on the plate's surface)
const front = vxf([0, LCD_M.cy * k, b.d / 2 - SCREEN_RECESS * k]);
front.appendChild(pagePanel(win, b.w / rects.face.w, 'lgs-asspod-screen'));
const front = vxf([0, geo.lcdCy * k, b.d / 2 - SCREEN_RECESS * k]);
front.appendChild(pagePanel(win, geo.winW * k / win.w, 'lgs-asspod-screen'));
parent.appendChild(front);
const [hx, hy, hz] = pd.box;
const faces = {
@@ -7275,8 +7537,23 @@ function createSceneGraph() {
// The body without podd: the model's parts as SteamVR render models (fixed lighting), the screen
// panel just in front of the plate.
function body(parent, b, rects, modelDir, finish) {
const k = b.w / MODEL_W;
function body(parent, b, rects, modelDir, finish, model, geo) {
const k = b.w / geo.w;
if (model === 'nano') {
// the nano's parts: its colour's aluminum, the shared caps, ports and well; the screen panel
// over its window
const scaled = vxf([0, 0, 0]);
scaled.setAttribute('scale', [k, k, k].map(r6).join(' '));
const wheel = finish === 'black' ? 'black' : 'white';
const parts = [`nano_shell_${finish}`, `nano_back_${finish}`, `nano_center_${finish}`, `nano_wheel_${wheel}`,
'nano_cap_top', 'nano_cap_bottom', 'nano_ports', 'nano_well'];
for (const name of parts) scaled.appendChild(vnode('rendermodel', { source: `${modelDir}/${name}/${name}.obj` }));
parent.appendChild(scaled);
const front = vxf([0, geo.lcdCy * k, b.d / 2 + OVERLAY_DZ * k]);
front.appendChild(pagePanel(rects.win, geo.winW * k / rects.win.w, 'lgs-asspod-screen'));
parent.appendChild(front);
return;
}
const fin = finish === 'white' ? 'white' : 'black';
const scaled = vxf([0, 0, 0]);
scaled.setAttribute('scale', [k, k, k].map(r6).join(' '));
@@ -7294,15 +7571,24 @@ function createSceneGraph() {
// The default place in the hand: where you put it with Reposition (2026-10-09, right hand), and
// its mirror image across the controller's centre line for the left hand.
function defaultHold(side) {
// Another body keeps its wheel where the Video's is (over the stick), front faces level.
function defaultHold(side, b, geo) {
const r = DEFAULT_HOLD;
if (side === 'right') return { rot: r.rot.slice(), centre: r.centre.slice() };
return { rot: [r.rot[0], r.rot[1], -r.rot[2], -r.rot[3]], centre: [-r.centre[0], r.centre[1], r.centre[2]] };
const h = side === 'right'
? { rot: r.rot.slice(), centre: r.centre.slice() }
: { rot: [r.rot[0], r.rot[1], -r.rot[2], -r.rot[3]], centre: [-r.centre[0], r.centre[1], r.centre[2]] };
const k = b.w / geo.w;
const wheelV = [0, VIDEO_GEO.wheelCy * VIDEO_SCALE, VIDEO_GEO.d * VIDEO_SCALE / 2];
const wheelM = [0, geo.wheelCy * k, b.d / 2];
const d = qrot(h.rot, wheelV.map((v, i) => v - wheelM[i]));
h.centre = h.centre.map((v, i) => v + d[i]);
return h;
}
/**
* Shows the assPod in `hand` ('left' | 'right') with the sheet over the menu.
* @param {{ hand: string, body: {w,h,d,r}, rects: {face, back, dim, text}, modelDir: string, finish: string,
* @param {{ hand: string, body: {w,h,d,r}, rects: {face, back, win, dim, text}, modelDir: string, finish: string,
* model?: 'video'|'nano', geo?: {w, d, lcdCy, lcdW, winW, winH, wheelCy} (model metres),
* podd?: { key, texW, texH, box, tiles }, hold?: { rot, centre }, world?: { rot, centre } }} o
* podd: its layout (podd-out.json) when podd draws the body; hold: a place in the hand other than
* the default; world: pinned in the room (standing space) instead of on the hand
@@ -7313,14 +7599,15 @@ function createSceneGraph() {
if (!attach()) return null;
clearNodes();
const side = o.hand === 'left' ? 'left' : 'right';
const { rot, centre } = o.hold && o.hold.rot && o.hold.centre ? o.hold : defaultHold(side);
const geo = o.geo || VIDEO_GEO;
const { rot, centre } = o.hold && o.hold.rot && o.hold.centre ? o.hold : defaultHold(side, o.body, geo);
// pinned in the room (Reposition: the controller moves, the assPod stays), else on the hand
const hand = o.world
? vxf(o.world.centre, o.world.rot)
: vxf([0, 0, 0], null, { 'parent-path': '/user/hand/' + side });
const hold = o.world ? vxf([0, 0, 0]) : vxf(centre, rot);
if (o.podd && o.podd.tiles && Array.isArray(o.podd.box)) poddBody(hold, o.body, o.rects, o.podd, o.video);
else body(hold, o.body, o.rects, o.modelDir, o.finish);
if (o.podd && o.podd.tiles && Array.isArray(o.podd.box)) poddBody(hold, o.body, o.rects, o.podd, o.video, geo);
else body(hold, o.body, o.rects, o.modelDir, o.finish, o.model || 'video', geo);
hand.appendChild(hold);
st.root.appendChild(hand);
// the sheet and the caption, on Steam's window
+248 -84
View File
@@ -1,9 +1,13 @@
// podd: the assPod's body with real shaders, for SteamVR's scene graph.
//
// SteamVR draws render models with fixed lighting only, so this renders the original model
// (device/asspod/model, exported from tools/asspod-model) itself on the GPU: clearcoat plastic on
// the front, mirror-polished steel on the back and edges, the engraved peach as satin, glass over the
// screen, all lit by a studio environment and one key light.
// SteamVR draws render models with fixed lighting only, so this renders the models (device/asspod/
// model) itself on the GPU, lit by a studio environment and one key light. Two bodies, picked by the
// spec's "model":
// video the Video (tools/asspod-model): clearcoat plastic on the front, mirror-polished steel on
// the back and edges, the engraved peach as satin, glass over the screen
// nano the nano, 2nd generation (tools/nano2-model): anodized aluminum (a tinted, bead-blasted
// metal under a thin oxide) with the laser-etched back paler and smoother, white plastic
// end caps, the click wheel and an aluminum center button
//
// How it reaches the headset: the body sits in a box of six panels in SteamVR's scene graph
// (device/asspod/src/sg.js), rigid with the hand. For each face and each eye, podd renders the model
@@ -13,11 +17,12 @@
// at the compositor's rate. The screen is not drawn here: the window shows the live screen panel
// inside the box (glass reflections over it, transparent otherwise).
//
// Input: --spec /dev/shm/lgs/podd.json {open, hand, rot [w,x,y,z], centre [x,y,z], scale, finish}
// (the body in the controller's frame, written by device/shell_ext/asspod.py)
// Input: --spec /dev/shm/lgs/podd.json {open, hand, rot [w,x,y,z], centre [x,y,z], scale, nanoScale, model, finish}
// (the body in the controller's frame, written by device/shell_ext/asspod.py; scale is the
// Video's, nanoScale the nano's; finish is the current model's colour)
// Output: --out /dev/shm/lgs/podd-out.json {pid, ok, key, texW, texH, box [hx,hy,hz], tiles {face: [x,y,w,h]}}
// tiles are the left eye's rect in texture pixels (v = 0 at the top), with `pad` px of
// overscan around each eye; the right eye's starts tw + 2 pad to its right. A face's panel
// tiles are the left eye's rect in texture pixels (v = 0 at the top), with `pad` px around
// each eye (PAD px of overscan, then BAND px clear); the right eye's starts tw + 2 pad to its right. A face's panel
// shows [tx - pad, tx + 2 tw + 3 pad] x [ty - pad, ty + th + pad]. The overlay's mouse scale
// is half the texture's width (one eye).
//
@@ -30,6 +35,7 @@
#include <time.h>
#include <array>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstring>
@@ -42,16 +48,38 @@
#include "json.h"
#include "vmath.h"
// ------------------------------------------------------------------ model constants (tools/asspod-model)
static const float BODY_W = 0.0618f, BODY_H = 0.1035f, BODY_D = 0.0110f;
static const float FRONT_Z = BODY_D / 2;
static const float LCD_CY = 0.0242f, LCD_W = 0.0508f, LCD_H = 0.0381f;
static const float WIN_W = 0.0532f, WIN_H = 0.0405f;
static const float WHEEL_CY = -0.0240f, WHEEL_R = 0.0197f, WHEEL_RC = 0.0073f, WHEEL_RM = 0.0135f;
// ------------------------------------------------------------------ the models (model metres)
struct Geo {
float w, h, d; // body
float lcdCy, lcdW, lcdH; // the LCD (transparent: the screen panel shows through)
float winW, winH, winR; // its window (winR: rounded corners drawn here; 0 = the plate's own hole)
float wheelCy, wheelRm; // the wheel's centre and the touch marker's radius
float markerSigma; // the marker's glow
float rockDeg, sink; // press motion
};
// tools/asspod-model GEOMETRY
static const Geo VIDEO = {0.0618f, 0.1035f, 0.0110f, 0.0242f, 0.0508f, 0.0381f, 0.0532f, 0.0405f, 0.f,
-0.0240f, 0.0135f, 0.0028f, 2.5f, 0.0005f};
// tools/nano2-model GEOMETRY
static const Geo NANO = {0.040f, 0.090f, 0.0065f, 0.02888f, 0.0296f, 0.0222f, 0.03162f, 0.02404f, 0.0006f,
-0.0133f, 0.0108f, 0.0021f, 2.2f, 0.0004f};
// The nano's anodized colours (tools/nano2-model FINISHES metal, sRGB) and roughness.
struct Finish { const char *name; float r, g, b, rough; };
static const Finish NANO_FINISHES[] = {
{"silver", 0xd3 / 255.f, 0xd6 / 255.f, 0xda / 255.f, 0.42f}, {"green", 0x7f / 255.f, 0xd1 / 255.f, 0x2a / 255.f, 0.40f},
{"blue", 0x1e / 255.f, 0xa9 / 255.f, 0xe3 / 255.f, 0.40f}, {"pink", 0xec / 255.f, 0x2c / 255.f, 0x95 / 255.f, 0.40f},
{"black", 0x16 / 255.f, 0x17 / 255.f, 0x1a / 255.f, 0.46f}, {"red", 0xe0 / 255.f, 0x12 / 255.f, 0x1b / 255.f, 0.40f},
};
static const float MARGIN = 0.004f; // m around the body: the box of panels
static const float PX_PER_M = 4200.f; // panel pixels per metre (about 1 px per 0.24 mm)
static const int PAD = 3; // px rendered past each face's edge: neighbouring faces overlap,
// so no panel edge (where texels blend with the gap) shows
static const int BAND = 4; // px left clear around each eye's image (outside its PAD): SteamVR
// splits a panel's rect between the eyes at its middle, and
// filtering at that seam took a sliver of the other eye's image,
// whose body colour at its edge showed as a faint line along the
// box's edge, about a cm off the body
static const int SPAN = PAD + BAND; // each eye's image to its block's edge
static const float kPi = 3.14159265f;
static volatile sig_atomic_t g_stop = 0;
@@ -188,10 +216,15 @@ in vec2 vUv;
in vec3 vModel;
uniform vec3 uEye; // body space
uniform mat3 uToWorld; // body -> world rotation (the environment is fixed in the room)
uniform int uMat; // 0 plate, 1 steel, 2 steel back (engraving), 3 wheel, 4 well, 5 ports, 6 window glass
uniform int uMat; // 0 plate, 1 steel, 2 steel back (engraving), 3 wheel, 4 well, 5 ports, 6 window glass,
// 7 anodized aluminum, 8 anodized back (engraving), 9 white plastic (texture), 10 palette (texture)
uniform vec3 uBase; // base colour (linear)
uniform sampler2D uTex; // wheel labels / engraving mask
uniform float uRough; // anodized: the blasted metal's roughness
uniform sampler2D uTex; // wheel labels / engraving mask / cap print / palette
uniform vec4 uMarker; // x angle (rad, 0 = top, clockwise), y opacity
uniform vec3 uWheel; // the wheel's centre y, the marker's radius, its glow (model metres)
uniform vec3 uLcd; // the LCD's centre y, half width, half height
uniform vec3 uWin; // the window's half width, half height, corner radius (0: square)
out vec4 frag;
const float PI = 3.14159265;
@@ -220,6 +253,15 @@ vec3 irradiance(vec3 n) {
return c + vec3(1.0, 0.98, 0.95) * max(dot(n, KEY), 0.0) * 0.32 + vec3(0.14) * max(dot(n, FILL), 0.0);
}
float fresnel(float f0, float cosT) { return f0 + (1.0 - f0) * pow(1.0 - cosT, 5.0); }
// value noise for the blasted aluminum's grain
float hash3(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
float vnoise(vec3 p) {
vec3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x), mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y);
float b = mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x), mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y);
return mix(a, b, f.z);
}
vec3 fresnel3(vec3 f0, float cosT) { return f0 + (1.0 - f0) * pow(1.0 - cosT, 5.0); }
void main() {
@@ -244,10 +286,10 @@ void main() {
col = base * irradiance(nw) + fresnel(0.04, cosT) * envColor(r, 0.45);
// the touch marker: a soft glow where the thumb is
if (uMarker.y > 0.001) {
vec2 p = vModel.xy - vec2(0.0, -0.0240);
vec2 m = vec2(sin(uMarker.x), cos(uMarker.x)) * 0.0135;
vec2 p = vModel.xy - vec2(0.0, uWheel.x);
vec2 m = vec2(sin(uMarker.x), cos(uMarker.x)) * uWheel.y;
float d = length(p - m);
col += vec3(0.55, 0.72, 1.0) * uMarker.y * exp(-d * d / (2.0 * 0.0028 * 0.0028)) * 0.9;
col += vec3(0.55, 0.72, 1.0) * uMarker.y * exp(-d * d / (2.0 * uWheel.z * uWheel.z)) * 0.9;
}
} else if (uMat == 4) {
col = uBase * irradiance(nw);
@@ -255,8 +297,12 @@ void main() {
col = uBase * irradiance(nw) + fresnel(0.3, cosT) * envColor(r, 0.35) * 0.5;
} else if (uMat == 6) {
// the window: black glossy border; the LCD itself transparent, with the glass's reflection
vec2 p = vModel.xy - vec2(0.0, 0.0242);
bool lcd = abs(p.x) < 0.0254 && abs(p.y) < 0.01905;
vec2 p = vModel.xy - vec2(0.0, uLcd.x);
if (uWin.z > 0.0) { // rounded corners (the nano's window lies on the aluminum)
vec2 q = abs(p) - (uWin.xy - uWin.z);
if (length(max(q, 0.0)) > uWin.z) discard;
}
bool lcd = abs(p.x) < uLcd.y && abs(p.y) < uLcd.z;
vec3 refl = fresnel(0.04, cosT) * envColor(r, 0.02);
if (lcd) {
float a = clamp(max(refl.r, max(refl.g, refl.b)), 0.0, 1.0);
@@ -265,6 +311,30 @@ void main() {
return;
}
col = vec3(0.004) + refl;
} else if (uMat == 7 || uMat == 8) {
// anodized aluminum: a dyed metal, bead-blasted (fine grain), under a thin clear oxide. The
// laser engraving on the back takes the dye and the blasting with it: paler and smoother.
float etch = uMat == 8 ? texture(uTex, vec2(vUv.x, 1.0 - vUv.y)).r : 0.0;
vec3 gp = vModel * 5200.0; // grain cells about 0.2 mm
float grain = vnoise(gp) * 0.65 + vnoise(gp * 2.3 + 7.1) * 0.35 - 0.5;
vec3 jitter = vec3(vnoise(gp + 3.1), vnoise(gp + 5.7), vnoise(gp + 9.2)) - 0.5;
vec3 ng = normalize(n + jitter * 0.07 * (1.0 - etch));
vec3 rg = normalize(uToWorld * reflect(-v, ng));
float cosG = clamp(dot(ng, v), 0.0, 1.0);
float rough = mix(uRough + grain * 0.08, 0.16, etch);
vec3 tint = mix(uBase, mix(uBase, vec3(0.85), 0.55), etch);
vec3 spec = fresnel3(tint, cosG) * envColor(rg, rough);
vec3 diff = tint * irradiance(nw) * 0.85; // the blasted surface scatters: its colour shows from any side
vec3 coat = fresnel(0.04, cosT) * envColor(r, 0.1) * 0.5;
col = (spec + diff) * (1.0 + grain * 0.1) + coat;
} else if (uMat == 9) {
// the nano's end caps: white satin plastic (the print from the texture)
vec3 base = pow(texture(uTex, vec2(vUv.x, 1.0 - vUv.y)).rgb, vec3(2.2));
col = base * irradiance(nw) * (1.0 - fresnel(0.04, cosT)) + fresnel(0.04, cosT) * envColor(r, 0.3) * 0.6;
} else if (uMat == 10) {
// the nano's ports: each part's colour from the palette texture
vec3 base = pow(texture(uTex, vec2(vUv.x, 1.0 - vUv.y)).rgb, vec3(2.2));
col = base * irradiance(nw) + fresnel(0.3, cosT) * envColor(r, 0.35) * 0.5 * max(base.r, 0.1);
} else {
// the front plate and centre button: plastic under a clear coat
vec3 diffuse = uBase * irradiance(nw);
@@ -283,8 +353,8 @@ struct Face {
const char *name;
v3 c, R, U, N; // body space: centre, right, up, outward normal
float w, h; // metres
int tx, ty, tw, th; // the left eye's tile (texture px, top-left origin) inside PAD px of overscan;
// the right eye's at tx + tw + 2 PAD
int tx, ty, tw, th; // the left eye's tile (texture px, top-left origin) inside PAD px of overscan
// and BAND px clear; the right eye's at tx + tw + 2 SPAN
};
struct Layout {
@@ -299,11 +369,12 @@ struct Layout {
static const int VIDEO_W = 320, VIDEO_H = 240;
static const char *VIDEO_FILE = "/dev/shm/lgs/podd-video";
static Layout makeLayout(float scale) {
// The box holds either body at its scale, so switching models keeps the layout (and the page's panels).
static Layout makeLayout(float scale, float nanoScale) {
Layout L;
L.hx = BODY_W * scale / 2 + MARGIN;
L.hy = BODY_H * scale / 2 + MARGIN;
L.hz = BODY_D * scale / 2 + MARGIN;
L.hx = std::max(VIDEO.w * scale, NANO.w * nanoScale) / 2 + MARGIN;
L.hy = std::max(VIDEO.h * scale, NANO.h * nanoScale) / 2 + MARGIN;
L.hz = std::max(VIDEO.d * scale, NANO.d * nanoScale) / 2 + MARGIN;
const float hx = L.hx, hy = L.hy, hz = L.hz;
L.f = {{
{"front", {0, 0, hz}, {1, 0, 0}, {0, 1, 0}, {0, 0, 1}, 2 * hx, 2 * hy},
@@ -314,17 +385,20 @@ static Layout makeLayout(float scale) {
{"bottom", {0, -hy, 0}, {1, 0, 0}, {0, 0, 1}, {0, -1, 0}, 2 * hx, 2 * hz},
}};
// two rows: front, back, right, left (all as tall as the body); then top and bottom. A face's block
// is both eyes with PAD px around each: [PAD | left eye | 2 PAD | right eye | PAD]
const int gap = 4;
// is both eyes, each in PAD px of overscan and BAND px clear: [SPAN | left eye | 2 SPAN | right eye | SPAN]. The blocks (and
// the opaque video tile) stand GAP px apart: a panel seen at a slant samples well past its own edge
// (coarse texture levels), and 4 px let it pick up a neighbour's body or the black video tile, a
// thin flickering line along the box's edges, a few mm off the body.
const int gap = 64;
int x = 0, row0 = 0;
for (int i = 0; i < 4; i++) {
Face &F = L.f[size_t(i)];
F.tw = int(std::lround(F.w * PX_PER_M));
F.th = int(std::lround(F.h * PX_PER_M));
F.tx = x + PAD;
F.ty = PAD;
x += 2 * F.tw + 4 * PAD + gap;
row0 = std::max(row0, F.th + 2 * PAD);
F.tx = x + SPAN;
F.ty = SPAN;
x += 2 * F.tw + 4 * SPAN + gap;
row0 = std::max(row0, F.th + 2 * SPAN);
}
int w0 = x;
x = 0;
@@ -333,10 +407,10 @@ static Layout makeLayout(float scale) {
Face &F = L.f[size_t(i)];
F.tw = int(std::lround(F.w * PX_PER_M));
F.th = int(std::lround(F.h * PX_PER_M));
F.tx = x + PAD;
F.ty = row0 + gap + PAD;
x += 2 * F.tw + 4 * PAD + gap;
row1 = std::max(row1, F.th + 2 * PAD);
F.tx = x + SPAN;
F.ty = row0 + gap + SPAN;
x += 2 * F.tw + 4 * SPAN + gap;
row1 = std::max(row1, F.th + 2 * SPAN);
}
// the video tile under the second row
L.vx = x;
@@ -426,6 +500,8 @@ struct Spec {
quat rot;
v3 centre;
float scale = 1.375f;
float nanoScale = 1.3125f;
std::string model = "video";
std::string finish = "black";
bool world = false; // pinned in the room (Reposition): wrot / wcentre in standing space
quat wrot;
@@ -441,6 +517,8 @@ static bool readSpec(const std::string &path, Spec &s) {
s.open = j.boolean("open", false);
s.hand = j.str("hand", "right");
s.scale = float(j.num("scale", 1.375));
s.nanoScale = float(j.num("nanoScale", 1.3125));
s.model = j.str("model", "video");
s.finish = j.str("finish", "black");
const JVal *r = j.get("rot"), *c = j.get("centre");
if (r && r->type == JVal::Arr && r->a.size() == 4) s.rot = qnorm({float(r->a[0].n), float(r->a[1].n), float(r->a[2].n), float(r->a[3].n)});
@@ -495,53 +573,74 @@ int main(int argc, char **argv) {
Program prog;
if (!linkProgram(prog, VS, FS, "pod")) return 1;
// the parts (both finishes' plates, wheels and buttons are loaded; the spec picks)
struct Part { std::string name; int mat; v3 base; Mesh mesh; GLuint tex = 0; bool wheel = false, center = false; };
// The parts of both models. A part with a variant is drawn only in that finish (the Video's
// plates; the nano's wheels: black on the black one, white on the others); a tinted part takes the
// nano's finish colour (one mesh for every colour). tex: the RGBA podd samples (a path in the
// model dir), if any.
struct Part {
std::string model, name, variant, tex;
int mat;
v3 base;
bool tint = false;
Mesh mesh;
GLuint texId = 0;
bool wheel = false, center = false;
};
const v3 W1 = {1, 1, 1};
std::vector<Part> parts = {
{"asspod_steel_back", 2, {0.9f, 0.9f, 0.9f}},
{"asspod_steel_edge", 1, {0.9f, 0.9f, 0.9f}},
{"asspod_ports", 5, {0.03f, 0.03f, 0.035f}},
{"asspod_plate_black", 0, {0.004f, 0.004f, 0.0045f}},
{"asspod_plate_white", 0, {0.86f, 0.86f, 0.84f}},
{"asspod_well_black", 4, {0.002f, 0.002f, 0.002f}},
{"asspod_well_white", 4, {0.25f, 0.25f, 0.24f}},
{"asspod_wheel_black", 3, {1, 1, 1}},
{"asspod_wheel_white", 3, {1, 1, 1}},
{"asspod_center_black", 0, {0.004f, 0.004f, 0.0045f}},
{"asspod_center_white", 0, {0.86f, 0.86f, 0.84f}},
{"video", "asspod_steel_back", "", "asspod_steel_back/asspod_steel_back_mask.rgba", 2, {0.9f, 0.9f, 0.9f}},
{"video", "asspod_steel_edge", "", "", 1, {0.9f, 0.9f, 0.9f}},
{"video", "asspod_ports", "", "", 5, {0.03f, 0.03f, 0.035f}},
{"video", "asspod_plate_black", "black", "", 0, {0.004f, 0.004f, 0.0045f}},
{"video", "asspod_plate_white", "white", "", 0, {0.86f, 0.86f, 0.84f}},
{"video", "asspod_well_black", "black", "", 4, {0.002f, 0.002f, 0.002f}},
{"video", "asspod_well_white", "white", "", 4, {0.25f, 0.25f, 0.24f}},
{"video", "asspod_wheel_black", "black", "asspod_wheel_black/asspod_wheel_black_color.rgba", 3, W1},
{"video", "asspod_wheel_white", "white", "asspod_wheel_white/asspod_wheel_white_color.rgba", 3, W1},
{"video", "asspod_center_black", "black", "", 0, {0.004f, 0.004f, 0.0045f}},
{"video", "asspod_center_white", "white", "", 0, {0.86f, 0.86f, 0.84f}},
{"nano", "nano_shell_silver", "", "", 7, W1, true},
{"nano", "nano_back_silver", "", "nano_back_mask/nano_back_mask.rgba", 8, W1, true},
{"nano", "nano_center_silver", "", "", 7, W1, true},
{"nano", "nano_wheel_white", "white", "nano_wheel_white/nano_wheel_white_color.rgba", 3, W1},
{"nano", "nano_wheel_black", "black", "nano_wheel_black/nano_wheel_black_color.rgba", 3, W1},
{"nano", "nano_cap_top", "", "nano_cap_top/nano_cap_top_color.rgba", 9, W1},
{"nano", "nano_cap_bottom", "", "nano_cap_bottom/nano_cap_bottom_color.rgba", 9, W1},
{"nano", "nano_ports", "", "nano_ports/nano_ports_color.rgba", 10, W1},
{"nano", "nano_well", "", "", 4, {0.002f, 0.002f, 0.002f}},
};
for (auto &p : parts) {
std::string dir = modelDir + "/" + p.name + "/";
if (!loadObj(dir + p.name + ".obj", p.mesh)) { std::printf("cannot load %s\n", p.name.c_str()); return 1; }
if (p.mat == 3) p.tex = loadRgba(dir + p.name + "_color.rgba");
if (p.mat == 2) p.tex = loadRgba(dir + p.name + "_mask.rgba");
if (!p.tex.empty()) p.texId = loadRgba(modelDir + "/" + p.tex);
p.wheel = p.name.find("wheel") != std::string::npos;
p.center = p.name.find("center") != std::string::npos;
}
// the window: a quad over the plate's opening (border + transparent LCD), in model metres
Mesh window;
{
const float z = FRONT_Z + 0.00008f, hw = WIN_W / 2, hh = WIN_H / 2, cy = LCD_CY;
// the windows: a quad over each model's screen (border + transparent LCD), in model metres
auto windowQuad = [](const Geo &G) {
Mesh m;
const float z = G.d / 2 + 0.00008f, hw = G.winW / 2, hh = G.winH / 2, cy = G.lcdCy;
const float q[] = {-hw, cy - hh, z, 0, 0, 1, 0, 0, hw, cy - hh, z, 0, 0, 1, 1, 0, hw, cy + hh, z, 0, 0, 1, 1, 1,
-hw, cy - hh, z, 0, 0, 1, 0, 0, hw, cy + hh, z, 0, 0, 1, 1, 1, -hw, cy + hh, z, 0, 0, 1, 0, 1};
glGenVertexArrays(1, &window.vao);
glGenBuffers(1, &window.vbo);
glBindVertexArray(window.vao);
glBindBuffer(GL_ARRAY_BUFFER, window.vbo);
glGenVertexArrays(1, &m.vao);
glGenBuffers(1, &m.vbo);
glBindVertexArray(m.vao);
glBindBuffer(GL_ARRAY_BUFFER, m.vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof q, q, GL_STATIC_DRAW);
for (int i = 0; i < 3; i++) glEnableVertexAttribArray(GLuint(i));
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 32, reinterpret_cast<void *>(0));
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 32, reinterpret_cast<void *>(12));
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 32, reinterpret_cast<void *>(24));
glBindVertexArray(0);
window.count = 6;
}
(void)LCD_W; (void)LCD_H; (void)WHEEL_R; (void)WHEEL_RC; (void)WHEEL_RM;
m.count = 6;
return m;
};
const Mesh windowVideo = windowQuad(VIDEO), windowNano = windowQuad(NANO);
Spec spec;
struct stat lastSt{};
Layout L = makeLayout(spec.scale);
float layoutScale = -1;
Layout L = makeLayout(spec.scale, spec.nanoScale);
float layoutScale = -1, layoutNanoScale = -1;
vr::VROverlayHandle_t ov = vr::k_ulOverlayHandleInvalid;
if (vr::VROverlay()->CreateOverlay(key.c_str(), "assPod", &ov) != vr::VROverlayError_None) {
@@ -608,7 +707,7 @@ int main(int argc, char **argv) {
auto writeOut = [&](bool ok, long frames, float fpsNow, float ms) {
std::string o = "{\"pid\": " + std::to_string(getpid()) + ", \"ok\": " + (ok ? "true" : "false") + ", \"key\": \"" + key +
"\", \"texW\": " + std::to_string(L.texW) + ", \"texH\": " + std::to_string(L.texH) + ", \"pad\": " + std::to_string(PAD) + ", \"box\": [" +
"\", \"texW\": " + std::to_string(L.texW) + ", \"texH\": " + std::to_string(L.texH) + ", \"pad\": " + std::to_string(SPAN) + ", \"box\": [" +
std::to_string(L.hx) + ", " + std::to_string(L.hy) + ", " + std::to_string(L.hz) + "], \"tiles\": {";
for (size_t i = 0; i < L.f.size(); i++) {
const Face &F = L.f[i];
@@ -649,6 +748,8 @@ int main(int argc, char **argv) {
handIn[0].path = pathL;
handIn[1].path = pathR;
float marker = 0, markerAngle = 0, sink = 0, rock = 0, rockAngle = 0;
std::string debugMode;
struct stat debugSt{};
long frames = 0;
uint64_t lastOut = 0, fpsT0 = nowNs();
@@ -670,9 +771,10 @@ int main(int argc, char **argv) {
lastSt = st;
readSpec(specPath, spec);
}
if (std::fabs(spec.scale - layoutScale) > 1e-5f) {
L = makeLayout(spec.scale);
if (std::fabs(spec.scale - layoutScale) > 1e-5f || std::fabs(spec.nanoScale - layoutNanoScale) > 1e-5f) {
L = makeLayout(spec.scale, spec.nanoScale);
layoutScale = spec.scale;
layoutNanoScale = spec.nanoScale;
if (!allocate()) { std::printf("cannot allocate %dx%d\n", L.texW, L.texH); return 1; }
writeOut(true, frames, 0, 0);
std::printf("layout %dx%d (scale %.3f)\n", L.texW, L.texH, double(spec.scale));
@@ -734,6 +836,21 @@ int main(int argc, char **argv) {
rock += (((clicked && !centred) ? 1.f : 0.f) - rock) * std::min(1.f, dt / 0.05f);
if (clicked && !centred) rockAngle = std::round(markerAngle / (kPi / 2)) * (kPi / 2);
// debugging: /tmp/lgs/podd-debug "blank" draws nothing; "dot" only a white dot at the front face's
// centre (each eye); "faces:front,left" only those faces
{
struct stat ds{};
if (stat("/tmp/lgs/podd-debug", &ds) == 0) {
if (ds.st_mtim.tv_sec != debugSt.st_mtim.tv_sec || ds.st_mtim.tv_nsec != debugSt.st_mtim.tv_nsec) {
debugSt = ds;
readFile("/tmp/lgs/podd-debug", debugMode);
while (!debugMode.empty() && std::isspace(static_cast<unsigned char>(debugMode.back()))) debugMode.pop_back();
}
} else {
debugMode.clear();
debugSt = {};
}
}
// render: every face, both eyes
DmaTarget &B = bufs[size_t(cur)];
glBindFramebuffer(GL_FRAMEBUFFER, msFbo);
@@ -745,28 +862,41 @@ int main(int argc, char **argv) {
glEnable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
glDisable(GL_BLEND);
const bool nano = spec.model == "nano";
const Geo &G = nano ? NANO : VIDEO;
Finish fin = NANO_FINISHES[0];
for (const Finish &f : NANO_FINISHES)
if (spec.finish == f.name) fin = f;
auto lin = [](float c) { return std::pow(c, 2.2f); };
prog.use();
prog.set("uScale", spec.scale);
prog.set("uScale", nano ? spec.nanoScale : spec.scale);
prog.set("uRough", fin.rough);
prog.set("uWheel", G.wheelCy, G.wheelRm, G.markerSigma);
prog.set("uLcd", G.lcdCy, G.lcdW / 2, G.lcdH / 2);
prog.set("uWin", G.winW / 2, G.winH / 2, G.winR);
prog.set("uTex", 0);
float toWorld[9];
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) toWorld[j * 3 + i] = body.r[i][j]; // column-major
glUniformMatrix3fv(prog.loc("uToWorld"), 1, GL_FALSE, toWorld);
prog.set("uMarker", markerAngle, marker, 0.f, 0.f);
const bool white = spec.finish == "white";
// the variant drawn: the Video's finish; the nano's wheel is black only on the black one
const std::string variant = nano ? (spec.finish == "black" ? "black" : "white") : (spec.finish == "white" ? "white" : "black");
for (int eye = 0; eye < 2; eye++) {
const Pose e2h = fromVR(vr::VRSystem()->GetEyeToHeadTransform(eye ? vr::Eye_Right : vr::Eye_Left));
const v3 eyeW = (hmd * e2h).t;
const v3 eyeB = toBody.apply(eyeW);
prog.set("uEye", eyeB.x, eyeB.y, eyeB.z);
for (const Face &F : L.f) {
if (debugMode == "blank" || debugMode == "dot") continue;
if (debugMode.rfind("faces:", 0) == 0 && debugMode.find(F.name) == std::string::npos) continue;
float P[16], V[16];
// the face grown by PAD px on every side (the panel shows it all)
Face G = F;
G.w = F.w * float(F.tw + 2 * PAD) / float(F.tw);
G.h = F.h * float(F.th + 2 * PAD) / float(F.th);
if (!faceMatrices(G, eyeB, P, V)) continue;
const int x = F.tx - PAD + eye * (F.tw + 2 * PAD);
Face grown = F;
grown.w = F.w * float(F.tw + 2 * PAD) / float(F.tw);
grown.h = F.h * float(F.th + 2 * PAD) / float(F.th);
if (!faceMatrices(grown, eyeB, P, V)) continue;
const int x = F.tx - PAD + eye * (F.tw + 2 * SPAN);
const int y = F.ty - PAD, vw = F.tw + 2 * PAD, vh = F.th + 2 * PAD;
// GL rows count from the buffer's first row (the texture's top)
glViewport(x, y, vw, vh);
@@ -775,15 +905,15 @@ int main(int argc, char **argv) {
prog.mat4("uProj", P);
prog.mat4("uView", V);
for (auto &p : parts) {
const bool isWhite = p.name.find("_white") != std::string::npos, isBlack = p.name.find("_black") != std::string::npos;
if ((isWhite && !white) || (isBlack && white)) continue;
if (p.model != (nano ? "nano" : "video")) continue;
if (!p.variant.empty() && p.variant != variant) continue;
Pose m; // the part's own motion, about the wheel's centre (model metres)
if (p.center) m.t = {0, 0, -0.0005f * sink};
if (p.center) m.t = {0, 0, -G.sink * sink};
if (p.wheel && rock > 0.001f) {
const float ang = -2.5f * kPi / 180 * rock;
const float ang = -G.rockDeg * kPi / 180 * rock;
const v3 axis = normalize(v3{std::cos(rockAngle), -std::sin(rockAngle), 0});
Pose R = poseFrom(qnorm({std::cos(ang / 2), axis.x * std::sin(ang / 2), axis.y * std::sin(ang / 2), 0}), {});
const v3 pivot = {0, WHEEL_CY, FRONT_Z};
const v3 pivot = {0, G.wheelCy, G.d / 2};
R.t = pivot - R.rotate(pivot);
m = R;
}
@@ -791,9 +921,10 @@ int main(int argc, char **argv) {
m.toGL(M);
prog.mat4("uModel", M);
prog.set("uMat", p.mat);
prog.set("uBase", p.base.x, p.base.y, p.base.z);
if (p.tint) prog.set("uBase", lin(fin.r), lin(fin.g), lin(fin.b));
else prog.set("uBase", p.base.x, p.base.y, p.base.z);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, p.tex);
glBindTexture(GL_TEXTURE_2D, p.texId);
glBindVertexArray(p.mesh.vao);
glDrawArrays(GL_TRIANGLES, 0, p.mesh.count);
}
@@ -803,10 +934,21 @@ int main(int argc, char **argv) {
id.toGL(M);
prog.mat4("uModel", M);
prog.set("uMat", 6);
glBindVertexArray(window.vao);
glDrawArrays(GL_TRIANGLES, 0, window.count);
const Mesh &win = nano ? windowNano : windowVideo;
glBindVertexArray(win.vao);
glDrawArrays(GL_TRIANGLES, 0, win.count);
}
}
if (debugMode == "dot") {
const Face &F = L.f[0];
glEnable(GL_SCISSOR_TEST);
glClearColor(1, 1, 1, 1);
for (int eye = 0; eye < 2; eye++) {
glScissor(F.tx + eye * (F.tw + 2 * SPAN) + F.tw / 2 - 4, F.ty + F.th / 2 - 4, 8, 8);
glClear(GL_COLOR_BUFFER_BIT);
}
glClearColor(0, 0, 0, 0);
}
glDisable(GL_SCISSOR_TEST);
glBindVertexArray(0);
// resolve the samples into the buffer SteamVR reads
@@ -840,6 +982,28 @@ int main(int argc, char **argv) {
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glFinish(); // SteamVR samples the dmabuf from another process
// debugging: the texture as SteamVR gets it, once, when /tmp/lgs/podd-dump.req appears
// ("RGBA", u32 width, u32 height, rows as stored: row 0 is the texture's top), with the spec
// and the eyes in body space beside it
if (access("/tmp/lgs/podd-dump.req", F_OK) == 0) {
std::remove("/tmp/lgs/podd-dump.req");
std::vector<uint8_t> px(size_t(L.texW) * L.texH * 4);
glBindFramebuffer(GL_READ_FRAMEBUFFER, B.fbo);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, L.texW, L.texH, GL_RGBA, GL_UNSIGNED_BYTE, px.data());
std::string d = "RGBA";
uint32_t wh[2] = {uint32_t(L.texW), uint32_t(L.texH)};
d.append(reinterpret_cast<const char *>(wh), 8);
d.append(reinterpret_cast<const char *>(px.data()), px.size());
writeAtomic("/tmp/lgs/podd-dump.rgba", d);
std::string eyes;
for (int eye = 0; eye < 2; eye++) {
const Pose e2h = fromVR(vr::VRSystem()->GetEyeToHeadTransform(eye ? vr::Eye_Right : vr::Eye_Left));
const v3 eb = toBody.apply((hmd * e2h).t);
eyes += std::string(eye ? ", " : "") + "[" + std::to_string(eb.x) + ", " + std::to_string(eb.y) + ", " + std::to_string(eb.z) + "]";
}
writeAtomic("/tmp/lgs/podd-dump.json", "{\"eyes\": [" + eyes + "], \"model\": \"" + spec.model + "\"}\n");
}
vr::SharedTextureHandle_t h = vr::SharedTextureHandle_t(B.vrHandle);
vr::Texture_t tex = {&h, vr::TextureType_SharedTextureHandle, vr::ColorSpace_Gamma};
auto e = vr::VROverlay()->SetOverlayTexture(ov, &tex);
Binary file not shown.
+10 -7
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Export the assPod's 3D body into device/asspod/model/ (SteamVR render models).
"""Export the assPods' 3D bodies into device/asspod/model/ (SteamVR render models).
The body is the original procedural model (tools/asspod-model/asspod-model.js, three.js). Its
exporter runs in a browser: this serves the repo on http://127.0.0.1:8766/, opens
tools/asspod-model/export.html, and writes the files the page sends back (only under
device/asspod/model/). Ctrl+C when it says "done".
The bodies are procedural three.js models: the Video (tools/asspod-model/asspod-model.js) and the
nano (tools/nano2-model/nano2-model.js). Their exporters run in a browser: this serves the repo on
http://127.0.0.1:8766/, opens the exporter page, and writes the files the page sends back (only
under device/asspod/model/). Ctrl+C when it says "done".
python tools/build_asspod_model.py [--no-open]
python tools/build_asspod_model.py [--no-open] [--page tools/nano2-model/export.html]
"""
import base64
import http.server
@@ -52,7 +52,10 @@ class Handler(http.server.SimpleHTTPRequestHandler):
def main():
srv = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
url = f"http://127.0.0.1:{PORT}/tools/asspod-model/export.html"
page = "tools/asspod-model/export.html"
if "--page" in sys.argv[1:]:
page = sys.argv[sys.argv.index("--page") + 1]
url = f"http://127.0.0.1:{PORT}/{page}"
print(f"serving {ROOT}; open {url}")
if "--no-open" not in sys.argv[1:]:
webbrowser.open(url)
+230
View File
@@ -0,0 +1,230 @@
<!doctype html>
<!-- Exports the assPod nano's 3D body (nano2-model.js) as SteamVR render models: one folder per part
under device/asspod/model/, each <name>.obj + <name>.mtl + texture (and raw RGBA where podd
samples it). SteamVR draws them in the hand without podd; podd (native/podd) draws the same
meshes with its own shaders (the anodized aluminum, the etched back, the white caps).
Run: python tools/build_asspod_model.py --page tools/nano2-model/export.html
Parts: nano_shell_<finish> (the tube: front, rolled sides and the cut rims), nano_back_<finish>
(the flat back, planar-mapped onto the engraving), nano_center_<finish> (the aluminum center
button), nano_wheel_<white|black>, nano_cap_top (white cap with the hold slider and HOLD),
nano_cap_bottom, nano_ports (slot, dock connector, jack: a palette texture), nano_well (the
dark well behind the wheel and the inside of the tube at the caps), nano_back_mask (the
engraving as a mask for podd). -->
<html><head><meta charset="utf-8"><title>assPod nano model export</title>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
</head><body>
<pre id="log">exporting…</pre>
<script type="module">
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { createNano2Model, GEOMETRY, FINISHES, drawWheel, engravingCanvas } from './nano2-model.js';
const { BODY, CAP } = GEOMETRY;
const FILES = {};
const log = (s) => { document.getElementById('log').textContent += '\n' + s; };
const DIR = 'device/asspod/model/';
// ---- textures
function canvas(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }
const b64 = (c) => c.toDataURL('image/png').split(',')[1];
// raw RGBA for podd: "RGBA", width, height (u32 little-endian), rows top first
function rgba(c) {
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
const out = new Uint8Array(12 + d.length);
out.set([82, 71, 66, 65]);
new DataView(out.buffer).setUint32(4, c.width, true);
new DataView(out.buffer).setUint32(8, c.height, true);
out.set(d, 12);
let bin = '';
for (let i = 0; i < out.length; i += 0x8000) bin += String.fromCharCode.apply(null, out.subarray(i, i + 0x8000));
return btoa(bin);
}
function solid(hex) {
const c = canvas(8, 8);
const g = c.getContext('2d');
g.fillStyle = hex;
g.fillRect(0, 0, 8, 8);
return c;
}
function scaled(src, w, h) {
const c = canvas(w, h);
const g = c.getContext('2d');
g.imageSmoothingQuality = 'high';
g.drawImage(src, 0, 0, w, h);
return c;
}
// ---- OBJ writer (as tools/asspod-model/export.html): non-indexed, positions/uvs/normals in model metres
function objText(name, geo) {
const g = geo.index ? geo.toNonIndexed() : geo;
const p = g.attributes.position, n = g.attributes.normal, t = g.attributes.uv;
const out = [`# ${name}: the assPod nano body (tools/nano2-model/export.html)`, `mtllib ${name}.mtl`, `usemtl ${name}`];
const f = (v) => v.toFixed(6);
for (let i = 0; i < p.count; i++) out.push(`v ${f(p.getX(i))} ${f(p.getY(i))} ${f(p.getZ(i))}`);
for (let i = 0; i < p.count; i++) out.push(`vt ${f(t ? t.getX(i) : 0.5)} ${f(t ? t.getY(i) : 0.5)}`);
for (let i = 0; i < p.count; i++) out.push(`vn ${f(n.getX(i))} ${f(n.getY(i))} ${f(n.getZ(i))}`);
for (let i = 0; i < p.count; i += 3) out.push(`f ${i + 1}/${i + 1}/${i + 1} ${i + 2}/${i + 2}/${i + 2} ${i + 3}/${i + 3}/${i + 3}`);
return out.join('\n') + '\n';
}
function mtlText(name, { ks = 0.04, ns = 256 } = {}) {
return `newmtl ${name}\nKd 1 1 1\nNs ${ns}\nd 1\nillum 2\nKa 0 0 0\nKs ${ks} ${ks} ${ks}\nmap_Kd ${name}_color.png\n`;
}
function part(name, geo, tex, mtl, { raw = false } = {}) {
const dir = DIR + name + '/';
FILES[dir + name + '.obj'] = { text: objText(name, geo) };
FILES[dir + name + '.mtl'] = { text: mtlText(name, mtl) };
FILES[dir + name + '_color.png'] = { b64: b64(tex) };
if (raw) FILES[dir + name + '_color.rgba'] = { b64: rgba(tex) };
log(`${name}: ${(geo.index ? geo.index.count : geo.attributes.position.count) / 3} triangles`);
}
// ---- geometry helpers
function baked(mesh, root) {
root.updateMatrixWorld(true);
const g = mesh.geometry.clone();
if (!g.attributes.normal) g.computeVertexNormals();
g.applyMatrix4(mesh.matrixWorld);
return g.index ? g.toNonIndexed() : g;
}
function keep(g, names) {
for (const k of Object.keys(g.attributes)) if (!names.includes(k)) g.deleteAttribute(k);
return g;
}
/** Splits a non-indexed geometry by a per-triangle test (positions of its three corners). */
function split(geo, test) {
const p = geo.attributes.position;
const lists = [[], []];
for (let i = 0; i < p.count; i += 3) {
const tri = [0, 1, 2].map((k) => new THREE.Vector3().fromBufferAttribute(p, i + k));
lists[test(tri) ? 0 : 1].push(i);
}
return lists.map((list) => {
const out = new THREE.BufferGeometry();
for (const [key, src] of Object.entries(geo.attributes)) {
const arr = new Float32Array(list.length * 3 * src.itemSize);
list.forEach((i, j) => { for (let k = 0; k < 3; k++) for (let c = 0; c < src.itemSize; c++) arr[(j * 3 + k) * src.itemSize + c] = src.array[(i + k) * src.itemSize + c]; });
out.setAttribute(key, new THREE.BufferAttribute(arr, src.itemSize));
}
return out;
});
}
function setUV(geo, fn) {
const p = geo.attributes.position;
const uv = new Float32Array(p.count * 2);
for (let i = 0; i < p.count; i++) {
const [u, v] = fn(p.getX(i), p.getY(i), p.getZ(i));
uv[i * 2] = u; uv[i * 2 + 1] = v;
}
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
return geo;
}
const merge = (list) => mergeGeometries(list.map((g) => keep(g.index ? g.toNonIndexed() : g, ['position', 'normal', 'uv'])));
const withUv = (g) => (g.attributes.uv ? g : setUV(g, () => [0.5, 0.5]));
// ---- the body, once (the geometry is the same for every finish)
const model = createNano2Model({ envMap: null, finish: 'silver', screenCanvas: canvas(176, 132) });
const P = model.parts;
const root = model.root;
const BACK_Z = -BODY.d / 2;
const onBack = (tri) => tri.every((v) => Math.abs(v.z - BACK_Z) < 1e-6);
const [backGeo, rollGeo] = split(baked(P.shell, root), onBack);
const shellGeo = merge([rollGeo, baked(P.front, root), baked(P.rim, root)]);
// the back, seen from behind: +x of the model is the viewer's left, so u runs from +x to -x
setUV(backGeo, (x, y) => [0.5 - x / BODY.w, 0.5 + y / BODY.h]);
const centerGeo = keep(baked(P.button, root), ['position', 'normal', 'uv']);
const wheelGeo = keep(baked(P.wheel, root), ['position', 'normal', 'uv']);
// caps: top (with the hold slider) and bottom, planar over x and z (seen from above, -z up)
const [capTop, capBottom] = split(baked(P.cap, root), (tri) => tri[0].y > 0);
const capUv = (g) => setUV(g, (x, y, z) => [0.5 + x / BODY.w, 0.5 - z / BODY.d]);
const capTopGeo = capUv(merge([withUv(capTop), withUv(baked(P.knob, root))]));
const capBottomGeo = capUv(merge([withUv(capBottom)]));
const wellGeo = merge([withUv(baked(P.well, root)), withUv(baked(P.wall, root))]);
// ports: their vertex colours become a palette texture, each vertex pointing at its colour's texel
const portsGeo = baked(P.ports, root);
const palette = [];
{
const col = portsGeo.attributes.color;
const uv = new Float32Array(col.count * 2);
const keyOf = (i) => [col.getX(i), col.getY(i), col.getZ(i)].map((v) => Math.round(v * 255)).join(',');
for (let i = 0; i < col.count; i++) {
const k = keyOf(i);
let idx = palette.indexOf(k);
if (idx < 0) { palette.push(k); idx = palette.length - 1; }
uv[i * 2] = idx; // fixed below, once the palette's size is known
uv[i * 2 + 1] = 0.5;
}
for (let i = 0; i < col.count; i++) uv[i * 2] = (uv[i * 2] + 0.5) / palette.length;
portsGeo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
keep(portsGeo, ['position', 'normal', 'uv']);
}
const paletteTex = canvas(palette.length * 4, 4);
palette.forEach((k, i) => {
const g = paletteTex.getContext('2d');
g.fillStyle = `rgb(${k})`;
g.fillRect(i * 4, 0, 4, 4);
});
// ---- textures shared by every finish
const BACK_W = 256, BACK_H = Math.round(256 * BODY.h / BODY.w);
const mask = scaled(engravingCanvas(), BACK_W, BACK_H); // white = engraved
FILES[DIR + 'nano_back_mask/nano_back_mask.rgba'] = { b64: rgba(mask) };
// the top cap: white plastic with HOLD printed right of the slot (nano2-model.js holdLabel)
const CAP_W = 512, CAP_H = Math.round(512 * BODY.d / BODY.w);
const capTex = canvas(CAP_W, CAP_H);
{
const g = capTex.getContext('2d');
const k = CAP_W / (BODY.w * 1000); // px per mm
g.fillStyle = '#f1f1ef';
g.fillRect(0, 0, CAP_W, CAP_H);
g.fillStyle = '#93969a';
g.font = `600 ${0.8 * k}px "Helvetica Neue", Helvetica, Arial, sans-serif`;
g.textBaseline = 'middle';
g.fillText('HOLD', (BODY.w * 1000 / 2 - 5.1) * k, CAP_H / 2);
}
for (const [key, fin] of Object.entries(FINISHES)) {
part(`nano_shell_${key}`, shellGeo, solid(fin.metal), { ks: 0.3, ns: 64 });
part(`nano_center_${key}`, centerGeo, solid(fin.metal), { ks: 0.3, ns: 64 });
// the back with its engraving: the bare aluminum shows paler through the laser mark
const back = canvas(BACK_W, BACK_H);
{
const g = back.getContext('2d');
g.fillStyle = fin.metal;
g.fillRect(0, 0, BACK_W, BACK_H);
const pale = new THREE.Color(fin.metal).lerp(new THREE.Color(0xffffff), key === 'black' ? 0.55 : 0.4);
const img = g.getImageData(0, 0, BACK_W, BACK_H);
const m = mask.getContext('2d').getImageData(0, 0, BACK_W, BACK_H).data;
const pr = [pale.r, pale.g, pale.b].map((v) => v * 255);
for (let i = 0; i < img.data.length; i += 4) {
const a = m[i] / 255;
for (let c = 0; c < 3; c++) img.data[i + c] = Math.round(img.data[i + c] * (1 - a) + pr[c] * a);
}
g.putImageData(img, 0, 0);
}
part(`nano_back_${key}`, backGeo, back, { ks: 0.3, ns: 64 });
}
for (const [name, fin] of [['white', FINISHES.silver], ['black', FINISHES.black]]) {
const c = canvas(1024, 1024);
drawWheel(c, fin);
part(`nano_wheel_${name}`, wheelGeo, scaled(c, 512, 512), { ks: 0.08, ns: 64 }, { raw: true });
}
part('nano_cap_top', capTopGeo, capTex, { ks: 0.1, ns: 64 }, { raw: true });
part('nano_cap_bottom', capBottomGeo, solid('#f1f1ef'), { ks: 0.1, ns: 64 }, { raw: true });
part('nano_ports', portsGeo, paletteTex, { ks: 0.2 }, { raw: true });
part('nano_well', wellGeo, solid('#050505'), { ks: 0 });
window.FILES = FILES;
log('exported ' + Object.keys(FILES).length + ' files');
fetch('/asspod-model', { method: 'POST', body: JSON.stringify(FILES) })
.then((r) => r.text()).then((t) => { log('saved: ' + t); window.DONE = true; })
.catch((e) => { log('not saved (open this page through tools/build_asspod_model.py): ' + e); window.DONE = true; });
</script>
</body></html>
+691
View File
@@ -0,0 +1,691 @@
// Procedural assPod nano (2nd generation): an extruded anodized-aluminum tube whose long sides
// roll a full half circle, cut square at both ends and closed by flat plastic caps (hold switch on
// top; dock connector and headphone jack on the bottom), a small LCD near the top and a click wheel
// that rocks and a center button that sinks. Everything is built from code and canvases; there are
// no asset files.
//
// Dimensions from the blueprint (90 x 40 x 6.5 mm, window 31.62 x 24.04 mm) and the user guide's
// front, top and bottom views (wheel, center button, hold switch and port positions).
//
// Local frame (as tools/asspod-model): meters, origin at the body center, +X right, +Y toward the
// hold switch, +Z out of the screen. Wheel angle: 0 = top (MENU), clockwise seen from the front.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { drawPeachSilhouette } from '../../device/asspod/src/brand/peach.js';
const MM = 0.001;
export const GEOMETRY = Object.freeze({
BODY: Object.freeze({ w: 40 * MM, h: 90 * MM, d: 6.5 * MM }),
WINDOW: Object.freeze({ cy: (45 - 4.1 - 24.04 / 2) * MM, w: 31.62 * MM, h: 24.04 * MM }),
LCD: Object.freeze({ w: 29.6 * MM, h: 22.2 * MM }), // 1.5", 176 x 132
WHEEL: Object.freeze({ cy: (45 - 58.3) * MM, rOuter: 15.2 * MM, rCenter: 6.55 * MM }),
CAP: Object.freeze({ wall: 0.4 * MM, recess: 0.25 * MM }),
});
const { BODY, WINDOW, LCD, WHEEL, CAP } = GEOMETRY;
const HW = BODY.w / 2;
const HH = BODY.h / 2;
const HD = BODY.d / 2;
const EDGE_R = HD; // the long sides are a full half circle
const FLAT_HW = HW - EDGE_R; // half-width of the flat front and back
const FRONT_Z = HD;
const BACK_Z = -HD;
const WELL_R = WHEEL.rOuter + 0.08 * MM; // hole in the front around the wheel
const WELL_DEPTH = 1.3 * MM;
const WHEEL_T = 0.8 * MM; // wheel and button thickness, so their sides show when they move
const CENTER_GAP = 0.15 * MM; // between the button and the wheel's inner edge
const ROCK_ANGLE = -2.2 * Math.PI / 180;
const SINK = 0.4 * MM;
const GRAIN_TILE = 16 * MM; // one grain texture tile spans 16 mm of aluminum
export const SECTOR_ANGLE = Object.freeze({ menu: 0, next: Math.PI / 2, play: Math.PI, prev: Math.PI * 1.5 });
// Anodized colors, picked against the 2006 lineup shot. `metal` is the tint of the aluminum
// (metalness 1), `wheel`/`label`/`gap` the click wheel. The end caps are white plastic on every one.
export const FINISHES = Object.freeze({
silver: { name: 'Silver', metal: '#d3d6da', rough: 0.42, wheel: '#f3f3f1', label: '#9a9da2', gap: '#b9bbbe' },
green: { name: 'Green', metal: '#7fd12a', rough: 0.4, wheel: '#f3f3f1', label: '#9a9da2', gap: '#b9bbbe' },
blue: { name: 'Blue', metal: '#1ea9e3', rough: 0.4, wheel: '#f3f3f1', label: '#9a9da2', gap: '#b9bbbe' },
pink: { name: 'Pink', metal: '#ec2c95', rough: 0.4, wheel: '#f3f3f1', label: '#9a9da2', gap: '#b9bbbe' },
black: { name: 'Black', metal: '#16171a', rough: 0.46, wheel: '#2e2f33', label: '#d9dadc', gap: '#18181a' },
red: { name: '(PRODUCT)RED', metal: '#e0121b', rough: 0.4, wheel: '#f3f3f1', label: '#9a9da2', gap: '#b9bbbe' },
});
// ---------------------------------------------------------------- geometry
// Cross-section in XZ: flat front and back joined by half-circle sides. Counter-clockwise from +X
// toward +Z. Each point carries its arc length, so the grain runs on unstretched around the roll.
const SIDE_SEGS = 24;
function crossSection(flatHw, r) {
const pts = [];
// right half circle, from the bottom of the back (-90 deg) up through +X to the front (+90 deg)
for (let i = 0; i <= SIDE_SEGS; i++) {
const a = -Math.PI / 2 + Math.PI * i / SIDE_SEGS;
pts.push([flatHw + Math.cos(a) * r, Math.sin(a) * r]);
}
// left half circle, from the front over -X to the back
for (let i = 0; i <= SIDE_SEGS; i++) {
const a = Math.PI / 2 + Math.PI * i / SIDE_SEGS;
pts.push([-flatHw + Math.cos(a) * r, Math.sin(a) * r]);
}
let s = 0;
return pts.map((p, i) => {
if (i > 0) s += Math.hypot(p[0] - pts[i - 1][0], p[1] - pts[i - 1][1]);
return { x: p[0], z: p[1], s };
});
}
// The two rolled sides and the back, swept straight along Y and cut square at both ends. The flat
// front is its own shape (frontFace) so it can carry the wheel's hole. Segments: 0..SIDE_SEGS-1
// roll the right side, SIDE_SEGS is the flat front (skipped), then the left side, then the closing
// segment is the flat back.
function buildTube() {
const ring = crossSection(FLAT_HW, EDGE_R);
const n = ring.length;
const perimeter = ring[n - 1].s + Math.hypot(ring[0].x - ring[n - 1].x, ring[0].z - ring[n - 1].z);
const pos = [];
const nor = [];
const uv = [];
const idx = [];
const ys = [-HH, -HH / 3, HH / 3, HH];
// The back is one long segment; it needs its own vertices for the wrap-around uv.
const verts = [...ring, { x: ring[0].x, z: ring[0].z, s: perimeter }];
for (const y of ys) {
for (let i = 0; i < verts.length; i++) {
const p = verts[i];
pos.push(p.x, y, p.z);
const cx = p.x >= 0 ? FLAT_HW : -FLAT_HW;
// flat back points (the closing segment) face -Z; roll points face out of their circle
const onBack = i === verts.length - 1 || (i === 0);
const nx = (p.x - cx) / EDGE_R, nz = p.z / EDGE_R;
if (onBack) nor.push(0, 0, -1); else nor.push(nx, 0, nz);
uv.push(p.s / GRAIN_TILE, y / GRAIN_TILE);
}
}
const rowLen = verts.length;
for (let j = 0; j < ys.length - 1; j++) {
for (let i = 0; i < rowLen - 1; i++) {
if (i === SIDE_SEGS) continue; // the flat front
const a = j * rowLen + i, b = a + 1, c = a + rowLen, d = b + rowLen;
idx.push(a, c, b, b, c, d); // ring runs counter-clockwise, rows run +Y: faces out
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
g.setIndex(idx);
// The two points at -90 deg on the right roll and at the end of the left roll belong to both
// the roll and the back; the roll end vertices keep the roll normal (which there is (0,-1)), so
// the shading meets the back without a crease.
return { geo: g, ring, perimeter };
}
// The flat front with the wheel's hole. Its uv continues the tube's arc length across the front.
function buildFront(ring) {
const sFrontStart = ring[SIDE_SEGS].s; // the front edge of the right roll, at x = +FLAT_HW
const shape = new THREE.Shape();
shape.moveTo(-FLAT_HW, -HH);
shape.lineTo(FLAT_HW, -HH);
shape.lineTo(FLAT_HW, HH);
shape.lineTo(-FLAT_HW, HH);
shape.closePath();
const hole = new THREE.Path();
hole.absarc(0, WHEEL.cy, WELL_R, 0, Math.PI * 2, true);
shape.holes.push(hole);
const g = new THREE.ShapeGeometry(shape, 96);
g.translate(0, 0, FRONT_Z);
const p = g.attributes.position;
const uv = g.attributes.uv;
for (let i = 0; i < p.count; i++) uv.setXY(i, (sFrontStart + FLAT_HW - p.getX(i)) / GRAIN_TILE, p.getY(i) / GRAIN_TILE);
return g;
}
// Ring of triangles between two equal-length point loops in a plane y = const, facing `dir`.
function annulus(outer, inner, y, dir, withUv) {
const pos = [];
const nor = [];
const uv = [];
const idx = [];
const n = outer.length;
for (const p of [...outer, ...inner]) {
pos.push(p.x, y, p.z);
nor.push(0, dir, 0);
uv.push(p.x / GRAIN_TILE, p.z / GRAIN_TILE);
}
for (let i = 0; i < n; i++) {
const a = i, b = (i + 1) % n, c = n + i, d = n + (i + 1) % n;
if (dir > 0) idx.push(a, c, b, b, c, d); else idx.push(a, b, c, b, d, c);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
if (withUv) g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
g.setIndex(idx);
return g;
}
function fan(ring, y, dir) {
const pos = [0, y, 0];
const nor = [0, dir, 0];
const idx = [];
for (const p of ring) { pos.push(p.x, y, p.z); nor.push(0, dir, 0); }
const n = ring.length;
for (let i = 0; i < n; i++) {
const a = 1 + i, b = 1 + (i + 1) % n;
if (dir > 0) idx.push(0, b, a); else idx.push(0, a, b);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
g.setIndex(idx);
return g;
}
// The inside of the tube between the cut end and a recessed cap (drawn double-sided).
function innerWall(ring, y0, y1) {
const pos = [];
const idx = [];
const n = ring.length;
for (const y of [y0, y1]) for (const p of ring) pos.push(p.x, y, p.z);
for (let i = 0; i < n; i++) {
const a = i, b = (i + 1) % n, c = n + i, d = n + (i + 1) % n;
idx.push(a, b, c, b, d, c);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setIndex(idx);
g.computeVertexNormals();
return g;
}
function stripTo(g, keep) {
const out = g.index ? g.toNonIndexed() : g;
for (const k of Object.keys(out.attributes)) if (!keep.includes(k)) out.deleteAttribute(k);
return out;
}
const onlyPositionNormal = (g) => stripTo(g, ['position', 'normal']);
// Both ends, cut square: the tube's wall as a thin aluminum rim and the plastic cap set into it.
function buildEnds() {
const outer = crossSection(FLAT_HW, EDGE_R);
const inner = crossSection(FLAT_HW, EDGE_R - CAP.wall);
const rims = [], caps = [], walls = [];
for (const dir of [1, -1]) {
const y = dir * HH;
rims.push(annulus(outer, inner, y, dir, true));
walls.push(innerWall(inner, y, y - dir * CAP.recess));
caps.push(fan(inner, y - dir * CAP.recess, dir));
}
return {
rim: mergeGeometries(rims.map((g) => stripTo(g, ['position', 'normal', 'uv']))),
cap: mergeGeometries(caps.map(onlyPositionNormal)),
wall: mergeGeometries(walls.map(onlyPositionNormal)),
};
}
// The well behind the wheel: a dark wall and floor, so a rocking wheel never shows the hollow body.
function buildWell() {
const wall = new THREE.CylinderGeometry(WELL_R, WELL_R, WELL_DEPTH, 96, 1, true);
wall.rotateX(Math.PI / 2);
wall.translate(0, WHEEL.cy, FRONT_Z - WELL_DEPTH / 2);
const floor = new THREE.CircleGeometry(WELL_R, 96);
floor.translate(0, WHEEL.cy, FRONT_Z - WELL_DEPTH);
return mergeGeometries([wall, floor].map(onlyPositionNormal));
}
function roundedRectShape(w, h, r, cx = 0, cy = 0) {
const s = new THREE.Shape();
const x0 = cx - w / 2, y0 = cy - h / 2, x1 = cx + w / 2, y1 = cy + h / 2;
const q = Math.PI / 2;
s.moveTo(x0 + r, y0);
s.lineTo(x1 - r, y0);
s.absarc(x1 - r, y0 + r, r, -q, 0, false);
s.lineTo(x1, y1 - r);
s.absarc(x1 - r, y1 - r, r, 0, q, false);
s.lineTo(x0 + r, y1);
s.absarc(x0 + r, y1 - r, r, q, 2 * q, false);
s.lineTo(x0, y0 + r);
s.absarc(x0 + r, y0 + r, r, 2 * q, 3 * q, false);
return s;
}
const stadiumShape = (w, h, cx = 0) => roundedRectShape(w, h, h / 2 - 1e-7, cx, 0);
function paint(g, hex) {
const c = new THREE.Color(hex);
const n = g.attributes.position.count;
const col = new Float32Array(n * 3);
for (let i = 0; i < n; i++) { col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b; }
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
return g;
}
// A flat shape laid on a cap face: up = the top cap (+Y), else the bottom. Shape x stays model x.
function onCap(shape, color, up, lift) {
const g = new THREE.ShapeGeometry(shape, 12);
const y = HH - CAP.recess + lift;
if (up) { g.rotateX(-Math.PI / 2); g.translate(0, y, 0); } else { g.rotateX(Math.PI / 2); g.translate(0, -y, 0); }
return paint(onlyPositionNormal(g), color);
}
// Hold switch (top), dock connector and headphone jack (bottom), measured off the user guide's end
// views: one vertex-colored mesh. The hold slider is separate (it is the cap's plastic).
const HOLD = Object.freeze({ x0: -17.6 * MM, x1: -6.3 * MM, h: 3.0 * MM, knobW: 4.6 * MM, knobH: 2.3 * MM });
function buildPorts() {
const parts = [];
// Hold switch: a pill-shaped slot, dark inside.
parts.push(onCap(stadiumShape(HOLD.x1 - HOLD.x0, HOLD.h, (HOLD.x0 + HOLD.x1) / 2), '#070708', true, 0.02 * MM));
// Dock connector: the opening with a square key hole at each end and the contact tongue.
const dockCx = -6.4 * MM;
parts.push(onCap(roundedRectShape(21.6 * MM, 2.4 * MM, 0.5 * MM, dockCx, 0), '#0b0b0c', false, 0.02 * MM));
parts.push(onCap(roundedRectShape(15.0 * MM, 1.3 * MM, 0.25 * MM, -6.5 * MM, 0), '#3b3c40', false, 0.04 * MM));
for (const x of [-16.1 * MM, 3.4 * MM]) parts.push(onCap(roundedRectShape(1.0 * MM, 1.0 * MM, 0.12 * MM, x, 0), '#2a2b2e', false, 0.04 * MM));
// Headphone jack: a bright metal ring around a black hole.
const jx = 16.45 * MM;
const ring = new THREE.Shape();
ring.absarc(jx, 0, 2.15 * MM, 0, Math.PI * 2, false);
const inner = new THREE.Path();
inner.absarc(jx, 0, 1.7 * MM, 0, Math.PI * 2, true);
ring.holes.push(inner);
parts.push(onCap(ring, '#a5a8ad', false, 0.02 * MM));
const hole = new THREE.Shape();
hole.absarc(jx, 0, 1.7 * MM, 0, Math.PI * 2, false);
parts.push(onCap(hole, '#000000', false, 0.02 * MM));
return mergeGeometries(parts);
}
// The hold slider: a raised pill parked at the left of the slot (hold off), with rounded shoulders.
function buildHoldKnob() {
const shape = stadiumShape(HOLD.knobW, HOLD.knobH, HOLD.x0 + 0.35 * MM + HOLD.knobW / 2);
const g = new THREE.ExtrudeGeometry(shape, {
depth: 0.25 * MM, bevelEnabled: true, bevelThickness: 0.15 * MM, bevelSize: 0.12 * MM, bevelSegments: 3, curveSegments: 16,
});
g.rotateX(-Math.PI / 2); // extrusion +Z becomes +Y
g.translate(0, HH - CAP.recess - 0.1 * MM, 0);
return g;
}
// ---------------------------------------------------------------- canvases
// Bead-blasted aluminum: blurred noise as a tileable normal map, plus the same noise as roughness
// variation. Built once and shared.
let grainMaps = null;
function getGrainMaps() {
if (grainMaps) return grainMaps;
const S = 512;
let h = new Float32Array(S * S);
for (let i = 0; i < h.length; i++) h[i] = Math.random();
// one wrap-around box blur: grains a couple of texels wide instead of single-pixel sparkle
for (let pass = 0; pass < 1; pass++) {
const o = new Float32Array(S * S);
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
let sum = 0;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) sum += h[((y + dy + S) % S) * S + ((x + dx + S) % S)];
o[y * S + x] = sum / 9;
}
}
h = o;
}
const nCv = document.createElement('canvas');
const rCv = document.createElement('canvas');
nCv.width = nCv.height = rCv.width = rCv.height = S;
const nImg = nCv.getContext('2d').createImageData(S, S);
const rImg = rCv.getContext('2d').createImageData(S, S);
const k = 9; // slope strength
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
const i = y * S + x;
const dx = (h[y * S + (x + 1) % S] - h[y * S + (x - 1 + S) % S]) * k;
const dy = (h[((y + 1) % S) * S + x] - h[((y - 1 + S) % S) * S + x]) * k;
const l = Math.hypot(dx, dy, 1);
nImg.data[i * 4] = Math.round((-dx / l * 0.5 + 0.5) * 255);
nImg.data[i * 4 + 1] = Math.round((dy / l * 0.5 + 0.5) * 255);
nImg.data[i * 4 + 2] = Math.round((1 / l * 0.5 + 0.5) * 255);
nImg.data[i * 4 + 3] = 255;
const r = Math.round(255 * (0.9 + 0.5 * (h[i] - 0.5))); // roughly 0.8..1 x roughness
rImg.data[i * 4] = rImg.data[i * 4 + 1] = rImg.data[i * 4 + 2] = Math.max(0, Math.min(255, r));
rImg.data[i * 4 + 3] = 255;
}
}
nCv.getContext('2d').putImageData(nImg, 0, 0);
rCv.getContext('2d').putImageData(rImg, 0, 0);
const normal = new THREE.CanvasTexture(nCv);
const rough = new THREE.CanvasTexture(rCv);
for (const t of [normal, rough]) { t.wrapS = t.wrapT = THREE.RepeatWrapping; }
grainMaps = { normal, rough };
return grainMaps;
}
const WHEEL_PX = 1024;
export function drawWheel(canvas, fin) {
const g = canvas.getContext('2d');
const S = WHEEL_PX;
const c = S / 2;
const pxPerM = S / (2 * WHEEL.rOuter);
const mm = pxPerM * MM;
g.clearRect(0, 0, S, S);
g.fillStyle = fin.gap;
g.fillRect(0, 0, S, S);
g.beginPath();
g.arc(c, c, c - 0.1 * mm, 0, Math.PI * 2);
g.fillStyle = fin.wheel;
g.fill();
g.fillStyle = fin.label;
const labelR = 11.4 * mm;
g.save();
g.font = `700 ${Math.round(2.1 * mm)}px "Myriad Pro", "Helvetica Neue", Helvetica, Arial, sans-serif`;
g.textBaseline = 'middle';
g.textAlign = 'left';
const track = 0.22 * mm;
let total = -track;
for (const ch of 'MENU') total += g.measureText(ch).width + track;
let x = c - total / 2;
for (const ch of 'MENU') { g.fillText(ch, x, c - labelR); x += g.measureText(ch).width + track; }
g.restore();
const triH = 1.7 * mm, triW = 1.4 * mm, barW = 0.4 * mm;
const tri = (x0, cy, dir) => {
g.beginPath();
g.moveTo(x0, cy - triH / 2);
g.lineTo(x0 + dir * triW, cy);
g.lineTo(x0, cy + triH / 2);
g.closePath();
g.fill();
};
{ // next
const w = triW * 2 + barW;
const x0 = c + labelR - w / 2;
tri(x0, c, 1);
tri(x0 + triW, c, 1);
g.fillRect(x0 + triW * 2 - 0.03 * mm, c - triH / 2, barW, triH);
}
{ // previous
const w = triW * 2 + barW;
const x1 = c - labelR + w / 2;
tri(x1, c, -1);
tri(x1 - triW, c, -1);
g.fillRect(x1 - triW * 2 - barW + 0.03 * mm, c - triH / 2, barW, triH);
}
{ // play/pause
const gap = 0.42 * mm, pw = 0.38 * mm;
const w = triW + gap + pw * 2 + 0.34 * mm;
const x0 = c - w / 2;
const cy = c + labelR;
tri(x0, cy, 1);
g.fillRect(x0 + triW + gap, cy - triH / 2, pw, triH);
g.fillRect(x0 + triW + gap + pw + 0.34 * mm, cy - triH / 2, pw, triH);
}
}
export function holdLabelCanvas() {
const cv = document.createElement('canvas');
cv.width = 256;
cv.height = 64;
const g = cv.getContext('2d');
g.fillStyle = '#fff';
g.font = '600 44px "Helvetica Neue", Helvetica, Arial, sans-serif';
g.textBaseline = 'middle';
g.fillText('HOLD', 4, 34);
return cv;
}
// The back's laser engraving as an alpha mask over the whole back (white = engraved).
export function engravingCanvas() {
const W = 512;
const H = Math.round(W * BODY.h / BODY.w);
const cv = document.createElement('canvas');
cv.width = W;
cv.height = H;
const g = cv.getContext('2d');
const k = W / (BODY.w / MM); // px per mm
g.fillStyle = '#000';
g.fillRect(0, 0, W, H);
const font = (weight, sizeMm) => `${weight} ${sizeMm * k}px "Myriad Pro", "Helvetica Neue", Helvetica, Arial, sans-serif`;
g.fillStyle = '#fff';
g.textAlign = 'center';
g.textBaseline = 'middle';
drawPeachSilhouette(g, W / 2, 36.5 * k, 10 * k, '#fff');
g.font = font(600, 6.2);
g.fillText('assPod', W / 2, 46.5 * k);
g.font = font(400, 2.1);
g.fillText('4GB', W / 2, 70 * k);
g.lineWidth = 0.18 * k;
g.strokeStyle = '#fff';
g.strokeRect(W / 2 - 3.6 * k, 68.6 * k, 7.2 * k, 2.8 * k);
g.font = font(400, 0.95);
g.fillText('Designed by assPod in California Assembled in a drawer', W / 2, 75.6 * k);
g.fillText('Model No. A1199-ASS Rated 5-30V 1A Max', W / 2, 77.1 * k);
return cv;
}
// ---------------------------------------------------------------- springs
// Exact critically damped step, stable at any dt.
function springStep(s, target, w, dt) {
const d = s.x - target;
const c = s.v + w * d;
const e = Math.exp(-w * dt);
s.x = target + (d + c * dt) * e;
s.v = (s.v - c * w * dt) * e;
}
// ---------------------------------------------------------------- model
/**
* @param {{ envMap?: THREE.Texture|null, finish?: keyof FINISHES, screenCanvas: HTMLCanvasElement, anisotropy?: number }} o
*/
export function createNano2Model({ envMap = null, finish = 'silver', screenCanvas, anisotropy = 1 }) {
const root = new THREE.Group();
root.name = 'nano2';
const grain = getGrainMaps();
grain.normal.anisotropy = grain.rough.anisotropy = anisotropy;
// Anodized aluminum: tinted metal, bead-blasted (the grain), under a thin sealed oxide that gives
// it a faint second sheen.
const metalMat = new THREE.MeshPhysicalMaterial({
color: 0xd3d6da, metalness: 1, roughness: 0.42, envMap, envMapIntensity: 1,
normalMap: grain.normal, normalScale: new THREE.Vector2(0.08, 0.08), roughnessMap: grain.rough,
clearcoat: 0.3, clearcoatRoughness: 0.3,
});
const capMat = new THREE.MeshStandardMaterial({ color: 0xf1f1ef, metalness: 0, roughness: 0.45, envMap });
const darkMat = new THREE.MeshStandardMaterial({ color: 0x050505, roughness: 0.8, side: THREE.DoubleSide });
const tube = buildTube();
const shell = new THREE.Mesh(tube.geo, metalMat);
shell.name = 'shell';
const front = new THREE.Mesh(buildFront(tube.ring), metalMat);
front.name = 'front';
const ends = buildEnds();
const rim = new THREE.Mesh(ends.rim, metalMat);
const cap = new THREE.Mesh(ends.cap, capMat);
cap.name = 'caps';
const wall = new THREE.Mesh(ends.wall, darkMat);
const well = new THREE.Mesh(buildWell(), darkMat);
const ports = new THREE.Mesh(buildPorts(), new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.4, metalness: 0.4, envMap }));
ports.name = 'ports';
const knob = new THREE.Mesh(buildHoldKnob(), capMat);
knob.name = 'hold';
const holdTex = new THREE.CanvasTexture(holdLabelCanvas());
holdTex.anisotropy = anisotropy;
const holdLabel = new THREE.Mesh(
new THREE.PlaneGeometry(4.6 * MM, 1.15 * MM),
new THREE.MeshBasicMaterial({ color: 0x8a8d92, alphaMap: holdTex, transparent: true, opacity: 0.9, depthWrite: false }),
);
holdLabel.rotation.x = -Math.PI / 2;
holdLabel.position.set(-5.4 * MM + 2.3 * MM + 0.2 * MM, HH - CAP.recess + 0.02 * MM, 0.05 * MM);
root.add(shell, front, rim, cap, wall, well, ports, knob, holdLabel);
// ---- screen: black window, LCD, glass
const screenTex = new THREE.CanvasTexture(screenCanvas);
screenTex.colorSpace = THREE.SRGBColorSpace;
screenTex.anisotropy = anisotropy;
const windowMesh = new THREE.Mesh(
new THREE.ShapeGeometry(roundedRectShape(WINDOW.w, WINDOW.h, 0.6 * MM), 4),
new THREE.MeshBasicMaterial({ color: 0x050607 }),
);
windowMesh.position.set(0, WINDOW.cy, FRONT_Z + 0.02 * MM);
const lcd = new THREE.Mesh(new THREE.PlaneGeometry(LCD.w, LCD.h), new THREE.MeshBasicMaterial({ map: screenTex, toneMapped: false }));
lcd.position.set(0, WINDOW.cy, FRONT_Z + 0.04 * MM);
lcd.name = 'lcd';
const glass = new THREE.Mesh(
new THREE.ShapeGeometry(roundedRectShape(WINDOW.w, WINDOW.h, 0.6 * MM), 4),
new THREE.MeshStandardMaterial({
color: 0x000000, metalness: 0, roughness: 0.04, envMap, envMapIntensity: 0.8,
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
}),
);
glass.position.set(0, WINDOW.cy, FRONT_Z + 0.08 * MM);
glass.renderOrder = 2;
root.add(windowMesh, lcd, glass);
// ---- click wheel (rocks as one group) and the aluminum center button (sinks)
const wheelGroup = new THREE.Group();
wheelGroup.position.set(0, WHEEL.cy, FRONT_Z);
root.add(wheelGroup);
const wheelCanvas = document.createElement('canvas');
wheelCanvas.width = wheelCanvas.height = WHEEL_PX;
const wheelTex = new THREE.CanvasTexture(wheelCanvas);
wheelTex.colorSpace = THREE.SRGBColorSpace;
wheelTex.anisotropy = anisotropy;
const wheelMat = new THREE.MeshStandardMaterial({ map: wheelTex, roughness: 0.42, metalness: 0, envMap, envMapIntensity: 0.6 });
const innerR = WHEEL.rCenter + CENTER_GAP;
const wheelTop = new THREE.RingGeometry(innerR, WHEEL.rOuter, 160, 1); // uvs: its bounding square = the canvas
const sideUv = (geo) => { const uv = geo.attributes.uv; for (let i = 0; i < uv.count; i++) uv.setXY(i, 0.5, 0.0005); return geo; };
const wheelSide = sideUv(new THREE.CylinderGeometry(WHEEL.rOuter, WHEEL.rOuter, WHEEL_T, 160, 1, true));
wheelSide.rotateX(Math.PI / 2);
wheelSide.translate(0, 0, -WHEEL_T / 2);
const wheelInner = sideUv(new THREE.CylinderGeometry(innerR, innerR, WHEEL_T, 96, 1, true));
wheelInner.rotateX(Math.PI / 2);
wheelInner.translate(0, 0, -WHEEL_T / 2);
const wheel = new THREE.Mesh(mergeGeometries([wheelTop, wheelSide, wheelInner].map((g) => stripTo(g, ['position', 'normal', 'uv']))), wheelMat);
wheel.material.side = THREE.DoubleSide; // the inner wall is seen from inside the ring
wheel.name = 'wheel';
wheelGroup.add(wheel);
const buttonGeo = new THREE.CylinderGeometry(WHEEL.rCenter, WHEEL.rCenter, WHEEL_T, 96, 1, false);
buttonGeo.rotateX(Math.PI / 2);
buttonGeo.translate(0, 0, -WHEEL_T / 2);
{ // grain uvs in the same scale as the body
const p = buttonGeo.attributes.position, uv = buttonGeo.attributes.uv;
for (let i = 0; i < p.count; i++) uv.setXY(i, p.getX(i) / GRAIN_TILE, p.getY(i) / GRAIN_TILE);
}
const button = new THREE.Mesh(buttonGeo, metalMat);
button.name = 'center';
wheelGroup.add(button);
// ---- back engraving: the laser takes the dye with it, so the mark reads paler and smoother
const engraveTex = new THREE.CanvasTexture(engravingCanvas());
engraveTex.anisotropy = anisotropy;
const engravingMat = new THREE.MeshStandardMaterial({
color: 0xffffff, metalness: 1, roughness: 0.22, envMap,
alphaMap: engraveTex, transparent: true, depthWrite: false,
});
const engravingGeo = new THREE.PlaneGeometry(BODY.w, BODY.h);
engravingGeo.rotateY(Math.PI); // faces -Z and reads unmirrored from behind
const engraving = new THREE.Mesh(engravingGeo, engravingMat);
engraving.position.set(0, 0, BACK_Z - 0.02 * MM);
engraving.renderOrder = 1;
engraving.name = 'engraving';
root.add(engraving);
// ---- state
let currentFinish = null;
function setFinish(key) {
const k = FINISHES[key] ? key : 'silver';
if (k === currentFinish) return;
currentFinish = k;
const f = FINISHES[k];
metalMat.color.set(f.metal);
metalMat.roughness = f.rough;
metalMat.clearcoat = k === 'black' ? 0.2 : 0.3;
// Undyed aluminum shows through the engraving: a pale version of the anodized tint.
engravingMat.color.set(f.metal).lerp(new THREE.Color(0xffffff), k === 'black' ? 0.55 : 0.4);
drawWheel(wheelCanvas, f);
wheelTex.needsUpdate = true;
}
setFinish(finish);
const rock = { x: 0, v: 0 };
let rockTarget = 0;
const rockAxis = new THREE.Vector3(1, 0, 0);
const sink = { x: 0, v: 0 };
let sinkTarget = 0;
const pressed = new Set();
function pressDown(part) {
if (pressed.has(part)) return;
pressed.add(part);
if (part === 'select') {
sinkTarget = 1;
} else if (part in SECTOR_ANGLE) {
const a = SECTOR_ANGLE[part];
rockAxis.set(Math.cos(a), -Math.sin(a), 0); // tilt the pressed side into the well
rockTarget = 1;
}
}
function pressUp(part) {
if (!pressed.delete(part)) return;
if (part === 'select') sinkTarget = 0;
else if (part in SECTOR_ANGLE) rockTarget = 0;
}
function update(dt) {
springStep(rock, rockTarget, rockTarget ? 70 : 38, dt);
springStep(sink, sinkTarget, sinkTarget ? 70 : 38, dt);
wheelGroup.quaternion.setFromAxisAngle(rockAxis, ROCK_ANGLE * rock.x);
button.position.z = -SINK * sink.x;
}
function markScreenDirty() { screenTex.needsUpdate = true; }
// ---- wheel hit testing
const _inv = new THREE.Matrix4();
const _ray = new THREE.Ray();
/** Where a ray meets the front plane, in wheel coords (units of rOuter, y up); null if it misses. */
function projectToWheel(raycaster, out = { x: 0, y: 0 }) {
root.updateWorldMatrix(true, false);
_inv.copy(root.matrixWorld).invert();
_ray.copy(raycaster.ray).applyMatrix4(_inv);
const dz = _ray.direction.z;
if (dz > -1e-9) return null; // parallel, or looking at the back
const t = (FRONT_Z - _ray.origin.z) / dz;
if (t < 0) return null;
out.x = (_ray.origin.x + _ray.direction.x * t) / WHEEL.rOuter;
out.y = (_ray.origin.y + _ray.direction.y * t - WHEEL.cy) / WHEEL.rOuter;
return out;
}
/** { angle (0 = MENU, clockwise), r (0..1), onCenter } or null off the wheel. */
function hitTestWheel(raycaster) {
const p = projectToWheel(raycaster);
if (!p) return null;
const r = Math.hypot(p.x, p.y);
if (r > 1) return null;
let angle = Math.atan2(p.x, p.y);
if (angle < 0) angle += Math.PI * 2;
return { angle, r, onCenter: r * WHEEL.rOuter < WHEEL.rCenter };
}
/** The button under a wheel hit: 'select' or the sector's name. */
function partAt(hit) {
if (hit.onCenter) return 'select';
const q = Math.round(hit.angle / (Math.PI / 2)) % 4;
return ['menu', 'next', 'play', 'prev'][q];
}
return {
root,
setFinish,
markScreenDirty,
pressDown,
pressUp,
update,
hitTestWheel,
projectToWheel,
partAt,
get finish() { return currentFinish; },
parts: { shell, front, rim, cap, wall, well, ports, knob, holdLabel, lcd, glass, wheel, button, wheelGroup, engraving },
};
}
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more