diff --git a/dash-apps/contest/prag-designs-monte-carlo/README.md b/dash-apps/contest/prag-designs-monte-carlo/README.md
new file mode 100644
index 00000000..e9605372
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/README.md
@@ -0,0 +1,46 @@
+# Monte Carlo
+
+**Category:** Rive
+**File:** [`monte-carlo.riv`](monte-carlo.riv) (about 930 KB), built from [`src/`](src/)
+
+
+
+Monte Carlo is a sports-car cluster behind one curved lens. Power runs across the top as a single ribbon from 0 to 10, with the redline marked at 8 to 10: the zone stays dark and textured until the live fill lights it. The speed sits large in the middle inside one crimson glow, with a carbon weave in the glass behind it. Gear on the left, the drive mode on the right (ASSIST or MANUAL), the posted limit beside the speed. Two narrow gauges hug the edges of the glass: battery temperature on the left, charge on the right. A quiet tab along the bottom carries the clock, the odometer and the charge in percent.
+
+When DashPilot starts, the cluster wakes like a car's: the glass lights, the top edge and the gold dip trace out from the centre, the power ribbon sweeps to the redline and back while the scale numbers light as it passes, then everything settles onto the live readings in about two seconds.
+
+## What it shows
+
+| Data | How |
+|---|---|
+| `egoSpeed` | the big number in the crimson glow. Drive over the limit and the glass heats red |
+| `powerLevel` | the power ribbon across the top, 0 to 10 (x10 % power), with a bright head on its leading edge. Negative power is regen: a short teal fill runs left of 0 |
+| `fusedSpeedLimit` | the posted limit beside the speed, hidden while unknown |
+| `gear` | the gear letter left of the speed |
+| `adasOn` | ASSIST in white or MANUAL in grey, right of the speed. The outgoing word hides at once and the new one fades in, so they never overlap |
+| `leftBlinker`, `rightBlinker` | an amber arrow above the gauges on that side, lit in step with the lamp |
+| `leftBlindSpot`, `rightBlindSpot` | the lens tip on that side glows amber for a car in your blind spot, red when it is urgent |
+| `trafficLightColor` | a small lamp under the limit, lit red, yellow or green |
+| `packTMax` | the left edge gauge, C to H |
+| `nominalEnergyRemaining`, `fullPackEnergy`, `energyBuffer` | the right edge gauge, E to F, with a red dash when the charge runs low, and the percentage in the bottom tab |
+| `odometer`, `currentTime` | the bottom tab |
+| `renderQuality` | the lens pass skips its bloom at the lowest setting |
+
+The instrument keeps its 2280 x 1080 design and scales to fit the screen (contain), centred. Speed is shown in km/h. There is no mph switch.
+
+## How it is built
+
+The lens, the ribbon, the numbers, the gauges and every data bind on `DashViewModel` are native Rive. A scripted layout (`src/v3_fx.luau`) draws a private copy of the instrument into a texture each frame and finishes it in a WGSL pass (`src/lensfx.wgsl`): the bloom, the head of the power ribbon, the redline heat and the start-up. The script also writes a few numbers of its own into the view model (`intro*`, `displayPower`) that drive the start-up and one smoothed power value both layers share. While it runs it sets `fxActive`, which hides the native copy underneath. If a runtime cannot run GPU Canvas, nothing in that layer draws and the native dash shows on its own, every reading still live. In DashPilot on Android the GPU layer needs a worker that supports GPU Canvas, which is what softwiredtech/dashpilot#267 turns on.
+
+- `src/v3.py` writes `src/scene.rml` from `viewmodel.part`, `converters.part`, `mine.part` and `v3.part`, using the helpers in `src/gen.py`. One `.rml` file on purpose: a project split across several publishes script signatures the web runtime rejects.
+- `src/viewmodel.part` is the `DashViewModel` from `rive/dash-demo/`, plus the properties of my own that only the script writes.
+- Rebuild: `cd src && python3 v3.py && rive . --publish`. This writes `../monte-carlo.riv` with the scripts signed. `rive . --once` builds without signing.
+
+Tested in DashPilot on Android (OnePlus 12R, 2780×1264 landscape) with the Demo data source: with GPU Canvas on (softwiredtech/dashpilot#267, Settings > Developer) the lens pass renders live, and with it off the native dash shows every reading. Also checked in the Rive web runtime with a simulated drive that sends the same values Demo mode does.
+
+## Credits
+
+- **Saira** font (`src/fonts/saira-italic-sub.ttf`, a subset of Saira Italic) by The Saira Project Authors, [SIL Open Font License 1.1](src/fonts/OFL-Saira.txt). It sets the speed, the gear, the mode and the scale.
+- **Exo 2** font (`src/fonts/exo2-*.ttf`) by The Exo 2 Project Authors, [SIL Open Font License 1.1](src/fonts/OFL-Exo2.txt).
+- **Inter** font (`src/fonts/inter-400.ttf`, `inter-600.ttf`) by The Inter Project Authors, [SIL Open Font License 1.1](https://github.com/rsms/inter/blob/master/LICENSE.txt). These are the same files `rive/dash-demo/` already ships.
+- Everything else (the lens, the ribbon, the gauges, the glow and weave, the icons, the shader and the start-up) is my own work in this folder. No images and no sounds.
diff --git a/dash-apps/contest/prag-designs-monte-carlo/monte-carlo.riv b/dash-apps/contest/prag-designs-monte-carlo/monte-carlo.riv
new file mode 100644
index 00000000..6821a30d
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/monte-carlo.riv differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/preview.jpg b/dash-apps/contest/prag-designs-monte-carlo/preview.jpg
new file mode 100644
index 00000000..51256217
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/preview.jpg differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/.gitignore b/dash-apps/contest/prag-designs-monte-carlo/src/.gitignore
new file mode 100644
index 00000000..3d433392
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/.gitignore
@@ -0,0 +1,2 @@
+build/
+__pycache__/
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/converters.part b/dash-apps/contest/prag-designs-monte-carlo/src/converters.part
new file mode 100644
index 00000000..eca31e2a
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/converters.part
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/OFL-Exo2.txt b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/OFL-Exo2.txt
new file mode 100644
index 00000000..222fbb54
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/OFL-Exo2.txt
@@ -0,0 +1,93 @@
+Copyright 2013 The Exo 2 Project Authors (https://github.com/NDISCOVER/Exo-2.0)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/OFL-Saira.txt b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/OFL-Saira.txt
new file mode 100644
index 00000000..dd1a514d
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/OFL-Saira.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Saira Project Authors (https://github.com/Omnibus-Type/Saira)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-500.ttf b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-500.ttf
new file mode 100644
index 00000000..c35ee0f6
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-500.ttf differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-600-italic.ttf b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-600-italic.ttf
new file mode 100644
index 00000000..0009a359
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-600-italic.ttf differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-700-italic.ttf b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-700-italic.ttf
new file mode 100644
index 00000000..8981c398
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-700-italic.ttf differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-700.ttf b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-700.ttf
new file mode 100644
index 00000000..2dd3639c
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-700.ttf differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-800-italic.ttf b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-800-italic.ttf
new file mode 100644
index 00000000..5fb97bbb
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/exo2-800-italic.ttf differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/inter-400.ttf b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/inter-400.ttf
new file mode 100644
index 00000000..3dda73d3
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/inter-400.ttf differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/inter-600.ttf b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/inter-600.ttf
new file mode 100644
index 00000000..6dc4fdf8
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/inter-600.ttf differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/fonts/saira-italic-sub.ttf b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/saira-italic-sub.ttf
new file mode 100644
index 00000000..7cc7db2e
Binary files /dev/null and b/dash-apps/contest/prag-designs-monte-carlo/src/fonts/saira-italic-sub.ttf differ
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/gen.py b/dash-apps/contest/prag-designs-monte-carlo/src/gen.py
new file mode 100644
index 00000000..75b0576f
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/gen.py
@@ -0,0 +1,557 @@
+"""Shared helpers: write Rive RML on the real DashViewModel.
+
+Three instruments, each a fixed layout box with hand-placed art inside a Node:
+energy well (left), speed dial (centre), assist well (right). Top/bottom bars and the
+blinker / blind-spot edge light are layouts. Every live value is a data binding.
+
+RML draw order: the FIRST sibling draws on TOP. stack() takes paint order (bottom
+first) and reverses it, so read every list bottom-up.
+"""
+import math
+
+_id = [0]
+
+
+def nid():
+ _id[0] += 1
+ return f"7:{_id[0]}"
+
+
+VM = {
+ "speed": "1:10", "gear": "1:11", "steer": "1:12", "lblink": "1:13", "rblink": "1:14", "door": "1:15",
+ "buckle": "1:16", "odo": "1:17", "set": "1:18", "limit": "1:19", "lbs": "1:20", "rbs": "1:21",
+ "light": "1:24", "stop": "1:25", "energy": "1:26", "power": "1:29", "volt": "1:30", "amps": "1:31",
+ "tmin": "1:34", "tmax": "1:35", "time": "1:36", "phone": "1:37", "adas": "1:50", "lane": "1:51",
+ "imperial": "1:55", "showPhone": "1:57", "showCar": "1:58", "showOdo": "1:59", "source": "1:70", "fx": "1:80", "laneScroll": "1:81", "introMain": "1:82", "introSide": "1:83", "introBars": "1:84", "introCount": "1:85", "introSweep": "1:86",
+}
+X, Y, ROT, SCALE_X, SCALE_Y, OPACITY, TEXT, TRIM_START, TRIM_END = 13, 14, 15, 16, 17, 18, 268, 114, 115
+
+# palette: warm ink on black, one orange, signal colours only for signals
+INK = "FFF6EFE4" # warm white
+INK2 = "FFA49E95" # engraved grey
+INK3 = "FF5C5852" # dim engraving
+TRACK = "FF2B2825"
+ORANGE = "FFFF8A1F"
+RED, AMBER, GREEN, BLUE = "FFFF4436", "FFFFB020", "FF39D46E", "FF5AA9FF"
+REG, MED = "2:900", "2:901" # Inter Regular / SemiBold
+
+
+def bind(prop, key, conv=None):
+ c = f' converterId="{conv}"' if conv else ""
+ return f''
+
+
+def stack(children):
+ return "".join(reversed([c for c in children if c]))
+
+
+# ---------------------------------------------------------------- paint + geometry helpers
+def solid(argb):
+ return f''
+
+
+def glow(strength, ox=0, oy=0, inner=False):
+ i = ' inner="true"' if inner else ""
+ return f''
+
+
+def fill(argb, extra=""):
+ return f'{solid(argb)}{extra}'
+
+
+def grad_fill(kind, stops, extra="", sx=0, sy=0, ex=0, ey=0):
+ s = "".join(f'' for c, p in stops)
+ xy = f' startX="{sx}" startY="{sy}" endX="{ex}" endY="{ey}"' if kind == "LinearGradient" else ""
+ return f'<{kind}{xy} name="G">{s}{kind}>{extra}'
+
+
+def stroke(argb, w, extra="", cap="butt"):
+ return f'{solid(argb)}{extra}'
+
+
+def grad_stroke(w, stops, sx, sy, ex, ey, extra="", cap="butt"):
+ st = "".join(f'' for c, p in stops)
+ return (f''
+ f'{st}{extra}')
+
+
+def rim_light(name, d, w, hot, cx, cy, sides=True, bottom=True, blur=14):
+ """Light catching a machined edge: hot at the sides and the bottom, dark at the top."""
+ r = d / 2
+ out = []
+ if sides:
+ st = [(hot, 0), ("00" + hot[2:], 0.32), ("00" + hot[2:], 0.68), (hot, 1)]
+ out += [shape(f"{name} Side Glow", ellipse(d) + grad_stroke(w * 3, st, -r, 0, r, 0, glow(blur)), cx, cy),
+ shape(f"{name} Side", ellipse(d) + grad_stroke(w, st, -r, 0, r, 0), cx, cy)]
+ if bottom:
+ st = [("00" + hot[2:], 0), ("00" + hot[2:], 0.55), (hot, 1)]
+ out += [shape(f"{name} Low Glow", ellipse(d) + grad_stroke(w * 3, st, 0, -r, 0, r, glow(blur)), cx, cy),
+ shape(f"{name} Low", ellipse(d) + grad_stroke(w, st, 0, -r, 0, r), cx, cy)]
+ return out
+
+
+def trim(start=0.0, end=1.0, binds=""):
+ return f'{binds}'
+
+
+def shape(name, body, x=0, y=0, rot=0.0, binds=""):
+ return f'{binds}{body}'
+
+
+def node(name, kids, x=0, y=0, rot=0.0, binds=""):
+ return f'{binds}{stack(kids)}'
+
+
+def ellipse(d):
+ return f''
+
+
+def rect(w, h, x=0, y=0, r=0):
+ rr = " ".join(f'cornerRadius{c}="{r}"' for c in ("TL", "TR", "BL", "BR")) if r else ""
+ return f''
+
+
+def poly(points, closed=True):
+ v = "".join(f'' for px, py in points)
+ return f'{v}'
+
+
+def text(name, runs, x, y, size, color=INK, font=MED, w=600, h=None, align="center", ox=0.5, spacing=0, binds="", glow_px=0):
+ """Free-placed text. runs: (text, bind_xml) or (text, bind_xml, size, color, font)."""
+ styles, rs = {}, []
+ for r in runs:
+ t, b = r[0], r[1]
+ key = (r[2] if len(r) > 2 else size, r[3] if len(r) > 3 else color, r[4] if len(r) > 4 else font)
+ if key not in styles:
+ styles[key] = nid()
+ rs.append(f'{b}')
+ paints = "".join(f''
+ f'{fill(c, glow(glow_px) if glow_px else "")}'
+ for (s, c, f), i in styles.items())
+ h = h or int(size * 1.35)
+ t = (f''
+ f'{paints}{"".join(rs)}')
+ return node(name + " Holder", [t], 0, 0, 0, binds) if binds else t
+
+
+def caps(name, t, x, y, size=17, color=INK2, spacing=3.5, binds="", align="center", ox=0.5, w=300):
+ return text(name, [(t, "")], x, y, size, color, MED, w=w, align=align, ox=ox, spacing=spacing, binds=binds)
+
+
+def polar(cx, cy, r, a):
+ return cx + r * math.sin(a), cy - r * math.cos(a)
+
+
+def box(name, w, h, kids, push=None):
+ """push = (centre x, centre y, intro property): the art scales up from 93% around its own centre and fades in."""
+ sid = nid()
+ art = stack(kids)
+ if push:
+ px, py, prop = push
+ art = (f''
+ f'{bind(prop, SCALE_X, "8:130")}{bind(prop, SCALE_Y, "8:130")}{bind(prop, OPACITY)}'
+ f'{art}')
+ return (f''
+ f''
+ f'{art}') # bare Shapes in a layout get stretched
+
+
+# ---------------------------------------------------------------- centre: the speed dial
+SW0 = math.radians(225) # 0 km/h sits at 7:30, the scale runs 270 degrees clockwise
+SWEEP = math.radians(270)
+
+
+def speed_dial():
+ W, H = 940, 940
+ cx, cy, R = 470, 470, 440 # R = outer rim
+ hub = 282
+ k = []
+ # body: warm haze around a machined bezel, the face recessed inside it
+ k += [shape("Haze", ellipse(2 * R + 120) + grad_fill("RadialGradient", [("00000000", 0.55), ("22FF7A1A", 0.82), ("00000000", 1)]), cx, cy),
+ shape("Dial Shadow", ellipse(2 * R + 40) + fill("FF000000", glow(50, 0, 24)), cx, cy),
+ shape("Bezel", ellipse(2 * R - 18) + grad_stroke(22, [("FF3B3732", 0), ("FF1C1A18", 0.5), ("FF0E0D0C", 1)], 0, -R, 0, R), cx, cy),
+ shape("Dial Face", ellipse(2 * R - 40) + grad_fill("RadialGradient", [("FF0F0E0D", 0), ("FF090908", 0.7), ("FF040404", 1)]), cx, cy),
+ shape("Face Inner Shadow", ellipse(2 * R - 40) + fill("CC000000", glow(28, 0, 10, inner=True)), cx, cy)]
+ k += rim_light("Bezel Outer", 2 * R, 2.5, "FFFF8A1F", cx, cy)
+ k += rim_light("Bezel Inner", 2 * R - 40, 1.5, "CCFFB066", cx, cy, bottom=False, blur=8)
+ k.append(shape("Bezel Top Sheen", ellipse(2 * R - 4) + stroke("40FFFFFF", 1.2, trim(0.9, 1.1)), cx, cy))
+ # scale track and graduations
+ k.append(shape("Scale Track", ellipse(2 * (R - 66)) + stroke(TRACK, 1.5), cx, cy))
+ for i in range(0, 81):
+ v = i * 2
+ a = SW0 + v / 160 * SWEEP
+ if v % 20 == 0:
+ L, Wd, c = 30, 4, "FFFFE2BC"
+ k.append(shape(f"Tick Glow {v}", rect(10, L + 6, 0, -(R - 30 - L / 2)) + fill("66FF9A3C", glow(10)), cx, cy, a))
+ elif v % 10 == 0:
+ L, Wd, c = 19, 2.5, "E6F6EFE4"
+ else:
+ L, Wd, c = 10, 1.5, "80F6EFE4"
+ k.append(shape(f"Tick {v}", rect(Wd, L, 0, -(R - 30 - L / 2)) + fill(c), cx, cy, a))
+ if v % 20 == 0:
+ tx, ty = polar(cx, cy, R - 108, a)
+ k.append(text(f"Num {v}", [(str(v), "")], tx, ty, 40, INK, REG, w=120))
+ # over-limit: the scale between the limit and the needle glows red
+ over = trim(0, 0.5, bind("limit", TRIM_START, "8:61") + bind("speed", TRIM_END, "8:61"))
+ k.append(node("Over Limit Arc", [
+ shape("Over Glow", ellipse(2 * (R - 45)) + stroke("99FF3A2E", 26, over + glow(16)), 0, 0, SW0),
+ shape("Over Line", ellipse(2 * (R - 45)) + stroke("CCFF4436", 6, over), 0, 0, SW0),
+ ], cx, cy, 0, bind("speed", OPACITY, "8:73")))
+ # limit index: a red triangle on the rim at the posted limit
+ tri = poly([(-11, -(R - 2)), (11, -(R - 2)), (0, -(R - 24))])
+ k.append(node("Limit Index", [shape("Limit Tri", tri + fill(RED))], cx, cy, SW0, bind("limit", ROT, "8:62")))
+ # cruise set: a pale blue chevron just inside the track
+ chev = poly([(-9, -(R - 70)), (9, -(R - 70)), (0, -(R - 86))])
+ k.append(node("Set Pivot", [node("Set Index", [shape("Set Tri", chev + fill(BLUE))], 0, 0, 0, bind("adas", OPACITY, "2:41"))],
+ cx, cy, SW0, bind("set", ROT, "8:62")))
+ # needle: a hot wedge of light from the hub rim through the scale
+ tip, base = -(R - 22), -(hub - 6)
+ wedge = poly([(-1.5, tip), (1.5, tip), (6, base), (-6, base)])
+ k.append(node("Needle", [
+ shape("Needle Bloom", rect(40, abs(tip - base), 0, (tip + base) / 2, 20) + fill("88FF7A1A", glow(34))),
+ shape("Needle Glow", wedge + fill("CCFF8A1F", glow(10))),
+ shape("Needle Blade", wedge + grad_fill("LinearGradient", [("FFFFE7A8", 0), ("FFFF9A2E", 0.45), ("FFFF6A10", 1)], sx=0, sy=tip, ex=0, ey=base)),
+ ], cx, cy, SW0, bind("speed", ROT, "8:137")))
+ # park: the scale rests under a dark veil
+ k.append(node("Park Veil", [shape("Veil", ellipse(2 * 364) + stroke("B3000000", 148))], cx, cy, 0, bind("gear", OPACITY, "8:100")))
+ # reverse: white reverse light rises along the bottom of the bezel
+ rev_trim = trim(0.36, 0.64)
+ k.append(node("Reverse Light", [
+ shape("Rev Glow", ellipse(2 * R) + stroke("88FFFFFF", 16, rev_trim + glow(20), "round")),
+ shape("Rev Line", ellipse(2 * R) + stroke("EEFFFFFF", 3, rev_trim, "round")),
+ ], cx, cy, 0, bind("gear", OPACITY, "8:102")))
+ # the raised hub: second plane, casting onto the face
+ k += [shape("Hub Shadow", ellipse(2 * hub + 30) + fill("FF000000", glow(40, 0, 30)), cx, cy),
+ shape("Hub", ellipse(2 * hub) + grad_fill("RadialGradient", [("FF161513", 0), ("FF0C0B0A", 0.8), ("FF080807", 1)]), cx, cy),
+ shape("Hub Rim", ellipse(2 * hub) + stroke("FF3A332C", 2), cx, cy),
+ shape("Hub Sheen", ellipse(2 * hub - 4) + grad_fill("LinearGradient", [("14FFFFFF", 0), ("00FFFFFF", 0.45)], sx=0, sy=-hub, ex=0, ey=hub), cx, cy),
+ shape("Hub Specular", ellipse(2 * hub - 10) + stroke("38FFFFFF", 1.5, trim(0.88, 1.12)), cx, cy)]
+ k += rim_light("Hub Lip", 2 * hub, 2, "DDFF8A1F", cx, cy, bottom=False, blur=12)
+ # moments: the instrument changes light with the road and the assist state
+ k.append(node("Assist Light", [shape("Assist Lip", ellipse(2 * hub) + stroke("CC5AA9FF", 2, glow(6)))], cx, cy, 0, bind("adas", OPACITY, "8:72")))
+ k.append(node("Red Wash", [shape("Red Rim", ellipse(2 * R) + stroke("DDFF3A2E", 3, glow(8)))], cx, cy, 0, bind("light", OPACITY, "8:70")))
+ k.append(node("Green Wash", [shape("Green Rim", ellipse(2 * R) + stroke("DD39D46E", 3, glow(8)))], cx, cy, 0, bind("light", OPACITY, "8:71")))
+ # hub contents
+ k.append(node("Limit Mark", [
+ shape("Limit Ring", ellipse(70) + stroke(RED, 2.5), cx, cy - 205),
+ text("Limit Value", [("80", bind("limit", TEXT, "2:1"))], cx, cy - 205, 28, "FFF2ECE4", MED, w=80),
+ shape("Limit Stem", rect(2, 26, cx, cy - 258) + fill("99FF4436")),
+ ], 0, 0, 0, bind("limit", OPACITY, "2:5")))
+ k.append(node("Speed Group", [
+ text("Speed Bloom", [("88", bind("speed", TEXT, "2:1"))], cx, cy - 6, 290, "66FFB070", MED, w=560, h=330, glow_px=26),
+ text("Speed", [("88", bind("speed", TEXT, "2:1"))], cx, cy - 6, 290, "FFFFF7EC", MED, w=560, h=330),
+ node("Units", [caps("Unit Km", "KM/H", cx - 18, cy + 172, 26, INK2, 6, bind("imperial", OPACITY, "2:43")),
+ caps("Unit Mi", "MPH", cx - 18, cy + 172, 26, INK2, 6, bind("imperial", OPACITY, "2:41"))]),
+ ], 0, 0, 0, bind("gear", OPACITY, "8:101")))
+ k.append(node("Park Group", [
+ text("Park P", [("P", "")], cx, cy - 16, 250, "FFF6EFE4", MED, w=400, h=300),
+ caps("Parked", "PARKED", cx, cy + 128, 22, INK2, 8),
+ ], 0, 0, 0, bind("gear", OPACITY, "8:100")))
+ k.append(caps("Reverse Label", "REVERSE", cx, cy + 228, 22, "FFF2F2F2", 8, bind("gear", OPACITY, "8:102")))
+ k.append(text("Over By", [("+", ""), ("8", bind("speed", TEXT, "8:21"))], cx + 68, cy + 172, 28, RED, MED, w=90,
+ align="left", ox=0, binds=bind("speed", OPACITY, "2:4")))
+ k.append(text("Set Speed", [("SET ", "", 22, "FF7FA6CF", MED), ("85", bind("set", TEXT, "2:1"))], cx, cy + 228, 24,
+ "FFB9D4F0", MED, w=220, spacing=4, binds=bind("adas", OPACITY, "2:41")))
+ # P R N D engraved in the open bottom of the scale; lit letter + an orange dot
+ for i, (g, conv) in enumerate((("P", "2:11"), ("R", "2:12"), ("N", "2:13"), ("D", "2:14"))):
+ gx = cx - 129 + i * 86
+ k.append(text(f"Gear {g} Dim", [(g, "")], gx, cy + 345, 42, INK3, MED, w=70))
+ k.append(node(f"Gear {g} On", [
+ text(f"Gear {g} Lit", [(g, "")], gx, cy + 345, 42, INK, MED, w=70),
+ shape(f"Gear {g} Dot", ellipse(10) + fill(ORANGE, ""), gx, cy + 384),
+ shape(f"Gear {g} Dot Glow", ellipse(18) + fill("88FF8A1F", glow(8)), gx, cy + 384),
+ ], 0, 0, 0, bind("gear", OPACITY, conv)))
+ return box("Speed Instrument", W, H, k, (cx, cy, "introMain"))
+
+
+# ---------------------------------------------------------------- wells
+SW_, SH_ = 600, 680 # satellite box; the dial sits a little low, tucked beside the big one
+SCX, SCY, SR = 300, 380, 268 # satellite centre and outer rim
+
+
+def sat_face(name):
+ """Same machined language as the speed dial, smaller."""
+ cx, cy, R = SCX, SCY, SR
+ k = [shape(f"{name} Shadow", ellipse(2 * R + 30) + fill("FF000000", glow(40, 0, 20)), cx, cy),
+ shape(f"{name} Bezel", ellipse(2 * R - 14) + grad_stroke(16, [("FF3B3732", 0), ("FF1C1A18", 0.5), ("FF0E0D0C", 1)], 0, -R, 0, R), cx, cy),
+ shape(f"{name} Face", ellipse(2 * R - 30) + grad_fill("RadialGradient", [("FF0F0E0D", 0), ("FF090908", 0.7), ("FF040404", 1)]), cx, cy),
+ shape(f"{name} Inner Shadow", ellipse(2 * R - 30) + fill("CC000000", glow(24, 0, 8, inner=True)), cx, cy)]
+ k += rim_light(f"{name} Rim", 2 * R, 1.6, "70FF8A1F", cx, cy, blur=10)
+ k += rim_light(f"{name} Inner Rim", 2 * R - 30, 1.0, "40FFB066", cx, cy, bottom=False, blur=6)
+ return k
+
+
+def regen_rim():
+ st = [("FF39D46E", 0), ("0039D46E", 0.5), ("FF39D46E", 1)]
+ d = 2 * SR
+ return node("Regen Light", [
+ shape("Regen Rim Glow", ellipse(d) + grad_stroke(12, st, -SR, 0, SR, 0, glow(22)), SCX, SCY),
+ shape("Regen Rim", ellipse(d) + grad_stroke(2.5, st, -SR, 0, SR, 0), SCX, SCY),
+ ], 0, 0, 0, bind("power", OPACITY, "8:75"))
+
+
+def energy_well():
+ """Left satellite: battery on the top half, power on the bottom half, numbers in the middle."""
+ k = sat_face("Energy")
+ k.append(regen_rim())
+ cx, cy, R = SCX, SCY, 200
+ BA = math.radians(75) # each half sweeps 150 degrees, leaving gaps at 3 and 9
+ # top half: battery, 0 at 8 o'clock to 100 at 4 o'clock... up and over
+ k.append(shape("Charge Track", ellipse(2 * R) + stroke(TRACK, 3, trim(0, 150 / 360)), cx, cy, -BA))
+ k.append(shape("Charge Glow", ellipse(2 * R) + stroke("8839D46E", 14, trim(0, 0.3, bind("energy", TRIM_END, "8:134")) + glow(14), "round"), cx, cy, -BA))
+ k.append(shape("Charge Arc", ellipse(2 * R) + stroke(GREEN, 4, trim(0, 0.3, bind("energy", TRIM_END, "8:134")), "round"), cx, cy, -BA))
+ k.append(node("Charge Knob", [shape("Knob", ellipse(12) + fill(INK), 0, -R)], cx, cy, 0, bind("energy", ROT, "8:135")))
+ for v in range(0, 101, 5):
+ a = -BA + v / 100 * 2 * BA
+ L, c = (16, "E6F6EFE4") if v % 20 == 0 else (8, "70F6EFE4")
+ k.append(shape(f"Charge Tick {v}", rect(2.5 if v % 20 == 0 else 2, L, 0, -(R - 14 - L / 2)) + fill(c), cx, cy, a))
+ if v % 20 == 0:
+ tx, ty = polar(cx, cy, R - 46, a)
+ k.append(text(f"Charge Num {v}", [(str(v), "")], tx, ty, 19, INK2, REG, w=70))
+ # bottom half: power, zero at 6 o'clock, regen to the left, power to the right
+ k.append(shape("Power Track", ellipse(2 * R) + stroke(TRACK, 3, trim(0, 150 / 360)), cx, cy, math.pi - BA))
+ k.append(shape("Power Fill Glow", ellipse(2 * R) + stroke("88FF8A1F", 12, trim(0, 0, bind("power", TRIM_END, "8:95")) + glow(12), "round"), cx, cy, math.pi, ) .replace('rotation="3.141593"', 'rotation="3.141593" scaleX="-1"'))
+ k.append(shape("Power Fill", ellipse(2 * R) + stroke(ORANGE, 4, trim(0, 0, bind("power", TRIM_END, "8:95")), "round"), cx, cy, math.pi).replace('rotation="3.141593"', 'rotation="3.141593" scaleX="-1"'))
+ k.append(shape("Regen Fill Glow", ellipse(2 * R) + stroke("8839D46E", 12, trim(0, 0, bind("power", TRIM_END, "8:97")) + glow(12), "round"), cx, cy, math.pi))
+ k.append(shape("Regen Fill", ellipse(2 * R) + stroke(GREEN, 4, trim(0, 0, bind("power", TRIM_END, "8:97")), "round"), cx, cy, math.pi))
+ for i, v in enumerate(range(-100, 101, 10)):
+ a = math.pi - v / 100 * BA
+ major = v % 50 == 0
+ L = 16 if major else 8
+ k.append(shape(f"Power Tick {v}", rect(2.5 if major else 2, L, 0, -(R - 14 - L / 2)) + fill("E6F6EFE4" if major else "70F6EFE4"), cx, cy, a))
+ blade = R - 26
+ k.append(node("Power Needle", [
+ shape("PN Glow", rect(10, 54, 0, -(blade - 27), 5) + fill("99FF7A1A", glow(10))),
+ shape("PN", rect(4, 54, 0, -(blade - 27), 2) + fill(ORANGE)),
+ ], cx, cy, math.pi, bind("power", ROT, "8:99")))
+ # the middle: the two readouts
+ k.append(text("Charge", [("69", bind("energy", TEXT, "8:133")), ("%", "", 30, INK2, REG)], cx, cy - 44, 72, INK, MED, w=320))
+ k.append(caps("Charge Label", "BATTERY", cx, cy + 4, 14))
+ k.append(text("Kilowatts", [("31", bind("amps", TEXT, "8:136")), (" kW", "", 22, INK2, REG)], cx, cy + 56, 38, INK, MED, w=320))
+ rx, ry = polar(cx, cy, R - 50, math.pi + BA - 0.12) # at the two ends of the power arc
+ px, py = polar(cx, cy, R - 50, math.pi - BA + 0.12)
+ k.append(caps("Regen L", "REGEN", rx + 8, ry, 13, "FF6FCF95", 2.5, w=110))
+ k.append(caps("Power L", "POWER", px - 8, py, 13, "FFFFA860", 2.5, w=110))
+ return box("Energy Instrument", SW_, SH_, k, (SCX, SCY, "introSide"))
+
+
+def outline_car(name, color, glow_color=None):
+ # top-down outline, nose up: body, raked windscreen, glass roof, rear window, mirrors
+ body = rect(64, 126, 0, 0, 24)
+ screen = poly([(-24, -30), (24, -30), (20, -10), (-20, -10)])
+ roof = rect(40, 38, 0, 12, 8)
+ rear = poly([(-20, 34), (20, 34), (23, 46), (-23, 46)])
+ hood = poly([(-14, -58), (-10, -40), (10, -40), (14, -58)], closed=False)
+ mirrors = rect(8, 6, -36, -16, 2) + rect(8, 6, 36, -16, 2)
+ kids = [shape(f"{name} Body", body + stroke(color, 2.5)),
+ shape(f"{name} Glass", screen + roof + rear + stroke(color, 1.6)),
+ shape(f"{name} Hood", hood + stroke(color, 1.2)),
+ shape(f"{name} Mirrors", mirrors + fill(color))]
+ if glow_color:
+ kids.insert(0, shape(f"{name} Halo", body + stroke(glow_color, 10, glow(14))))
+ return kids
+
+
+def tinted_car(name, argb):
+ """The same glossy car, tinted: multiply turns white paint into the warning colour, black stays black."""
+ halo = shape(f"{name} Halo", rect(70, 150, 0, 0, 30) + fill("66" + argb[2:], glow(26)))
+ img = (f'')
+ tint = f'{rect(90, 175, 0, 0, 20)}{fill(argb)}'
+ return [halo, img, tint]
+
+
+def ego_car():
+ """Our car: a glossy studio render (fx2/car.png, ours), screened onto the face so its black drops out."""
+ img = (f'')
+ halo = shape("Ego Floor Light", ellipse(150) + fill("22FFF2E0", glow(40)), 0, 10)
+ beams = node("Reverse Beams", [
+ shape("Beam L Soft", poly([(-28, 74), (-12, 74), (-2, 190), (-66, 190)]) + fill("CCFFFFFF", glow(22))),
+ shape("Beam R Soft", poly([(12, 74), (28, 74), (66, 190), (2, 190)]) + fill("CCFFFFFF", glow(22))),
+ shape("Beam L Core", poly([(-24, 76), (-16, 76), (-14, 150), (-40, 150)])
+ + grad_fill("LinearGradient", [("CCFFFFFF", 0), ("00FFFFFF", 1)], sx=0, sy=76, ex=0, ey=150)),
+ shape("Beam R Core", poly([(16, 76), (24, 76), (40, 150), (14, 150)])
+ + grad_fill("LinearGradient", [("CCFFFFFF", 0), ("00FFFFFF", 1)], sx=0, sy=76, ex=0, ey=150)),
+ ], 0, 0, 0, bind("gear", OPACITY, "8:102"))
+ return [beams, halo, img]
+
+
+def assist_well():
+ k = sat_face("Assist")
+ cx, cy = SCX, SCY
+ # lane marks: fine dotted lines (decoration; the car does not report lanes)
+ # they scroll past at the car's speed (the GPU script writes laneScroll, 0..18 px); soft ends hide the wrap
+ marks = [node(f"Lane {s} {j}", [shape(f"Lane Mark {s} {j}", rect(2, 8, cx + s * 78, cy - 226 + j * 18) + fill("66F6EFE4"))],
+ 0, 0, 0, bind("laneScroll", OPACITY, f"8:{110 + j}")) for s in (-1, 1) for j in range(18)]
+ k.append(node("Lane Marks", marks, 0, 0, 0, bind("laneScroll", Y)))
+ for side, prop, urgent in ((-1, "lbs", "8:106"), (1, "rbs", "8:107")):
+ x = cx + side * 150
+ k.append(node(f"Blind {side} Slide", [
+ node(f"Blind {side}", tinted_car(f"Blind Car {side}", "FFFFB020"), x, cy - 50, 0, bind(prop, OPACITY, "8:105")),
+ node(f"Urgent {side}", tinted_car(f"Urgent Car {side}", "FFFF4436"), x, cy - 50, 0, bind(prop, OPACITY, urgent)),
+ ], 0, 0, 0, bind(prop, Y, "8:104")))
+ k.append(node("Ego", ego_car(), cx, cy - 70, 0, bind("steer", ROT, "8:67")))
+ k.append(node("Assist On", [shape("Assist Dot", ellipse(12) + fill(BLUE), cx - 82, cy + 108),
+ shape("Assist Dot Glow", ellipse(22) + fill("885AA9FF", glow(8)), cx - 82, cy + 108),
+ caps("Assist On Text", "ASSIST ON", cx + 14, cy + 108, 18, "FFD6E6F7", 4)],
+ 0, 0, 0, bind("adas", OPACITY, "2:41")))
+ k.append(caps("Assist Off", "MANUAL", cx, cy + 108, 18, INK3, 4, bind("adas", OPACITY, "2:43")))
+ k.append(caps("Lane Change", "CHANGING LANE", cx, cy + 140, 14, AMBER, 3, bind("lane", OPACITY, "2:41")))
+ # traffic light jewels + the distance to the stop line
+ for i, (c, conv, nm) in enumerate(((RED, "2:11", "Red"), ("FFFFC53D", "2:13", "Yellow"), (GREEN, "2:12", "Green"))):
+ lx = cx - 40 + i * 40 # centred in the dial
+ k.append(shape(f"{nm} Socket", ellipse(26) + fill("FF1A1917") + stroke("FF2E2B27", 1.5), lx, cy + 172))
+ k.append(node(f"{nm} On", [shape(f"{nm} Jewel Glow", ellipse(36) + fill("AA" + c[2:], glow(12)), lx, cy + 172),
+ shape(f"{nm} Jewel", ellipse(20) + fill(c), lx, cy + 172)], 0, 0, 0, bind("light", OPACITY, conv)))
+ k.append(node("Stop", [text("Stop Dist", [("42", bind("stop", TEXT, "8:24")), (" m", "", 16, INK2, REG)], cx, cy + 208, 24, INK, MED,
+ w=160)], 0, 0, 0, bind("stop", OPACITY, "2:5"))) # under the lights
+ return box("Assist Instrument", SW_, SH_, k, (SCX, SCY, "introSide"))
+
+
+# ---------------------------------------------------------------- bars and edges (layouts)
+def lc(name, kids="", w=None, h=None, direction=None, align=None, gap=0, pad=None, binds=""):
+ sid = nid()
+ st = [f'layoutWidthScaleType="{"fixed" if w is not None else "fill"}"',
+ f'layoutHeightScaleType="{"fixed" if h is not None else "fill"}"']
+ if direction:
+ st.append(f'flexDirectionValue="{direction}"')
+ if align:
+ st.append(f'layoutAlignmentType="{align}"')
+ if gap:
+ k = "Horizontal" if direction == "row" else "Vertical"
+ st.append(f'gap{k}="{gap}" gap{k}UnitsValue="points"')
+ if pad:
+ for side, v in zip(("Top", "Right", "Bottom", "Left"), pad):
+ st.append(f'padding{side}="{v}" padding{side}UnitsValue="points"')
+ size = (f' width="{w}"' if w is not None else "") + (f' height="{h}"' if h is not None else "")
+ return (f'{binds}'
+ f'{kids}')
+
+
+def bar_text(name, runs, w, size, color, align, binds=""):
+ t = text(name, runs, w / 2 if align == "center" else (0 if align == "left" else w), 24, size, color, MED, w=w,
+ h=48, align=align, ox=0.5 if align == "center" else (0 if align == "left" else 1))
+ return lc(name + " Box", f'{t}', w, 48, binds=binds)
+
+
+def top_bar():
+ clock = bar_text("Clock", [("07", bind("time", TEXT, "2:28")), (":", ""), ("25", bind("time", TEXT, "2:30"))], 260, 38, INK, "left")
+ door = bar_text("Door", [("DOOR OPEN", "")], 220, 18, AMBER, "center", bind("door", OPACITY, "2:5"))
+ belt = bar_text("Belt", [("SEATBELT", "")], 200, 18, RED, "center", bind("buckle", OPACITY, "2:7"))
+ warn = lc("Warnings", door + belt, 440, 48, "row", "center", 16)
+ source = bar_text("Source", [("demo", bind("source", TEXT))], 120, 20, INK3, "right")
+ phone = bar_text("Phone", [("82", bind("phone", TEXT, "2:1")), ("%", "", 24, INK2, REG)], 120, 34, INK, "right",
+ bind("showPhone", OPACITY, "2:41"))
+ right = lc("Status", source + phone, 260, 48, "row", "centerRight", 12)
+ return lc("Top Bar", clock + warn + right, None, 48, "row", "spaceBetweenCenter", binds=bind("introBars", OPACITY))
+
+
+def bottom_bar():
+ odo = bar_text("Odo", [("ODO ", "", 16, INK3, MED), ("42180", bind("odo", TEXT, "2:1")), (" km", "", 18, INK2, REG)],
+ 360, 26, INK, "left", bind("showOdo", OPACITY, "2:41"))
+ volt = bar_text("Volt", [("396", bind("volt", TEXT, "2:1")), (" V", "", 18, INK2, REG)], 200, 26, INK, "right")
+ return lc("Bottom Bar", odo + volt, None, 48, "row", "spaceBetweenCenter", binds=bind("introBars", OPACITY))
+
+
+def edges():
+ def strip(name, side, w, color, prop, conv):
+ a, b = (color, "00" + color[2:]) if side == "left" else ("00" + color[2:], color)
+ g = (f''
+ f'')
+ sid = nid()
+ S = side.capitalize()
+ return (f'{bind(prop, OPACITY, conv)}'
+ f''
+ f'{g}')
+ return (strip("Blind L Urgent Edge", "left", 80, "CCFF4436", "lbs", "2:33") + strip("Blind L Edge", "left", 44, "CCFFB020", "lbs", "2:5")
+ + strip("Blind R Urgent Edge", "right", 80, "CCFF4436", "rbs", "2:34") + strip("Blind R Edge", "right", 44, "CCFFB020", "rbs", "2:5")
+ # blinker = the real lamp phase: a fast attack layer plus a slow afterglow, like a bulb
+ + strip("Blink L Afterglow", "left", 220, "55FF9A20", "lblink", "8:69") + strip("Blink L Edge", "left", 110, "AAFFA020", "lblink", "8:68")
+ + strip("Blink R Afterglow", "right", 220, "55FF9A20", "rblink", "8:69") + strip("Blink R Edge", "right", 110, "AAFFA020", "rblink", "8:68"))
+
+
+SM = """
+
+
+
+
+
+
+
+
+ """
+
+
+def content():
+ main = lc("Main", energy_well() + speed_dial() + assist_well(), None, None, "row", "spaceBetweenCenter")
+ return lc("Root", top_bar() + main + bottom_bar(), None, None, "column", pad=(22, 48, 18, 48))
+
+
+def fallback_edges():
+ sid = nid()
+ return (f'{bind("fx", OPACITY, "2:43")}'
+ f''
+ f'{edges()}')
+
+
+def fx_layer():
+ """Full-size GPU layer. If the runtime cannot run scripts it draws nothing and the native copy stays.
+ Absolute box pinned to all four edges, so it follows the artboard as DashPilot stretches it."""
+ sid = nid()
+ return (f''
+ f''
+ f''
+ f'')
+
+
+def scene():
+ # Dash: what DashPilot loads. Native instrument (hidden once the GPU layer runs) + the GPU layer on top.
+ native = content().replace('
+
+
+ {fx_layer()}
+ {fallback_edges()}
+ {native}
+ {SM.format(s="", a="4:1", n="Dash", m="5:1", l="5:10", st="5:11")}
+ '''
+ # Instrument: the same instrument, drawn by the GPU layer into a texture with the live view model.
+ inst = f'''
+
+
+ {edges()}
+ {content()}
+ {SM.format(s=" I", a="6:4", n="Instrument", m="6:5", l="6:6", st="6:7")}
+ '''
+ return f'''
+
+
+{dash}
+{inst}
+
+'''
+
+
+def merged(doc):
+ """One .rml only: a project split over several .rml files publishes script signatures the web runtime rejects."""
+ import re
+ parts = []
+ for f in ("viewmodel.part", "converters.part", "mine.part"):
+ s = open(f).read()
+ s = re.sub(r"]*>", "", s, count=1)
+ parts.append(s[:s.rindex("")])
+ return doc.replace("", "\n".join(parts) + "\n")
+
+
+if __name__ == "__main__":
+ open("scene.rml", "w").write(merged(scene()))
+ print("scene.rml written,", _id[0], "ids")
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/lensfx.wgsl b/dash-apps/contest/prag-designs-monte-carlo/src/lensfx.wgsl
new file mode 100644
index 00000000..eb2f15cd
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/lensfx.wgsl
@@ -0,0 +1,144 @@
+// v3 finish pass, over the native instrument rendered to a texture.
+// p is in artboard points (1080 tall). The bar geometry is the same as v3.py's, in target-2 pixels:
+// tx = p.x / K, ty = (p.y - OY) / K. Calm when driving; the start-up gets the show.
+struct U {
+ a: vec4, // w, h, time, renderQuality (1-3)
+ b: vec4, // power fill 0-1 (incl. the start-up sweep), regen 0-1, redline heat 0-1, over the limit 0-1
+ c: vec4, // red, green, yellow (smoothed 0-1), seconds into the start-up (99 = done)
+ d: vec4, // left lamp, right lamp, sweep 0-1, light at the top of the sweep 0-1
+ e: vec4, // K, OY, glow centre x, y
+ f: vec4, // fit: design offset x, y on screen, design scale, -
+};
+@group(0) @binding(0) var fullTex: texture_2d;
+@group(0) @binding(1) var smallTex: texture_2d;
+@group(0) @binding(2) var samp: sampler;
+@group(0) @binding(3) var u: U;
+
+struct VOut { @builtin(position) pos: vec4, @location(0) uv: vec2 };
+
+@vertex
+fn vs(@builtin(vertex_index) i: u32) -> VOut {
+ var p = array, 3>(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
+ var o: VOut;
+ o.pos = vec4(p[i], 0.0, 1.0);
+ o.uv = vec2(p[i].x * 0.5 + 0.5, 1.0 - (p[i].y * 0.5 + 0.5));
+ return o;
+}
+
+fn tap(uv: vec2) -> vec3 {
+ return textureSampleLevel(fullTex, samp, clamp(uv, vec2(0.0), vec2(1.0)), 0.0).rgb;
+}
+
+fn bloom(uv: vec2, res: vec2, radius: f32, knee0: f32) -> vec3 {
+ var sum = vec3(0.0);
+ var weight = 0.0;
+ let duv = vec2(radius) / res; // radius in artboard points
+ for (var j = -1; j <= 1; j = j + 1) {
+ for (var i = -1; i <= 1; i = i + 1) {
+ let o = vec2(f32(i), f32(j));
+ let w = select(1.0, 2.0, i == 0) * select(1.0, 2.0, j == 0);
+ let s = textureSampleLevel(smallTex, samp, uv + o * duv, 0.0).rgb;
+ let hi = max(s.r, max(s.g, s.b));
+ let lo = min(s.r, min(s.g, s.b));
+ let chroma = (hi - lo) / max(hi, 0.001);
+ let emitter = smoothstep(0.18, 0.45, chroma); // coloured light blooms, white text does not
+ let knee = smoothstep(knee0, knee0 + 0.25, hi);
+ sum += s * emitter * knee * w;
+ weight += w;
+ }
+ }
+ return sum / weight;
+}
+
+fn sq(x: f32) -> f32 { return x * x; }
+
+fn barArc(x: f32) -> f32 {
+ let a = 144.0 / (821.5 * 821.5);
+ let z = a * (x - 1083.5);
+ return (z * sqrt(1.0 + z * z) + log(z + sqrt(1.0 + z * z))) / (2.0 * a); // asinh, spelled out
+}
+
+fn barX(f: f32) -> f32 {
+ let wanted = mix(barArc(262.0), barArc(1905.0), clamp(f, 0.0, 1.0));
+ var x = mix(262.0, 1905.0, clamp(f, 0.0, 1.0));
+ for (var i = 0; i < 3; i = i + 1) {
+ let slope = 144.0 * (x - 1083.5) / (821.5 * 821.5);
+ x = x - (barArc(x) - wanted) / sqrt(1.0 + slope * slope);
+ }
+ return clamp(x, 262.0, 1905.0);
+}
+
+fn baseY(x: f32) -> f32 { // the bar's baseline, target px
+ let v = (x - 1083.5) / (1905.0 - 1083.5);
+ return 136.0 + 72.0 * v * v;
+}
+
+@fragment
+fn fs(in: VOut) -> @location(0) vec4 {
+ let res = u.a.xy;
+ let t = u.a.z;
+ let q = u.a.w;
+ let uv = in.uv;
+ let p = (uv * res - u.f.xy) / u.f.z; // design points (2280 x 1080); the screen may be wider or narrower
+ let K = u.e.x;
+ let OY = u.e.y;
+ let tp = vec2(p.x / K, (p.y - OY) / K); // target-2 pixels
+ let it = u.c.w; // seconds into the start-up
+ // the glass, approximated: light from the start-up and the moments stays inside it
+ let lv = pow(abs(tp.x - 1086.0) / 1062.0, 4.0) + pow(abs(tp.y - 356.5) / 280.0, 2.2);
+ let inLens = 1.0 - smoothstep(0.82, 1.0, lv);
+
+ let base = tap(uv);
+ var col = base;
+
+ // --- bloom: the glow, the dip and the fill spill light; at the top of the sweep, more
+ if (q > 1.5) {
+ var b = bloom(uv, res, 6.0, 0.40) * 0.18;
+ if (q > 2.5) {
+ b += bloom(uv, res, 18.0, 0.40) * 0.10;
+ }
+ col += b * inLens;
+ }
+
+ // --- the power bar
+ let fillv = u.b.x;
+ let hx = barX(fillv);
+ let hy = baseY(hx) - 21.0;
+ let hd = tp - vec2(hx, hy);
+ let on = smoothstep(0.004, 0.02, fillv);
+ // the head: a tight light along the bar's tangent, on the native sliver
+ let slope = 144.0 * (hx - 1083.5) / (821.5 * 821.5);
+ let tangent = normalize(vec2(1.0, slope));
+ let normal = vec2(-tangent.y, tangent.x);
+ let local = vec2(dot(hd, tangent), dot(hd, normal));
+ let core = exp(-0.5 * (sq(local.x / 2.5) + sq(local.y / 11.0)));
+ let halo = exp(-0.5 * (sq(local.x / 15.0) + sq(local.y / 20.0)));
+ col += (vec3(1.0, 0.8353, 0.4863) * core * 0.32 + vec3(1.0, 0.3882, 0.0706) * halo * 0.08) * on * inLens;
+ // redline heat: when the fill reaches 7-8 the red block glows, and a red haze rises off it
+ let byv = baseY(clamp(tp.x, 1576.4, 1905.0));
+ let heat = u.b.z;
+ let above = max(byv - 42.0 - tp.y, 0.0);
+ let redSoft = smoothstep(1564.4, 1592.4, tp.x) * (1.0 - smoothstep(1889.0, 1917.0, tp.x));
+ let outside = 1.0 - smoothstep(byv - 46.0, byv - 40.0, tp.y);
+ col += vec3(0.78, 0.06, 0.03) * redSoft * exp(-above / 18.0) * outside * heat * 0.06 * inLens;
+ // regen: a cool light at the left end
+ let rd = tp - vec2(230.0, baseY(230.0) - 21.0);
+ col = col + vec3(0.35, 0.85, 0.78) * exp(-dot(rd, rd) / (2.0 * 50.0 * 50.0)) * u.b.y * 0.18 * inLens;
+
+ let gc = u.e.zw;
+ let gd = (p - gc) / vec2(1.0, 0.62);
+ let gr = length(gd);
+
+ // --- over the limit: the glow deepens to red
+ col = col + vec3(0.55, 0.02, 0.02) * u.b.w * exp(-gr * gr / (2.0 * 300.0 * 300.0)) * 0.12 * inLens;
+
+ // --- blinkers: amber light along the glass on that side, with the lamp
+ let amber = vec3(1.0, 0.62, 0.12);
+ col = col + amber * (u.d.x * exp(-p.x / 140.0) + u.d.y * exp(-(2280.0 - p.x) / 140.0)) * 0.22 * inLens;
+
+ // --- finish: keep the native type and colour; soft-limit only the light this pass added
+ let added = max(col - base, vec3(0.0));
+ let light = added / (vec3(1.0) + added);
+ let finished = base + (vec3(1.0) - clamp(base, vec3(0.0), vec3(1.0))) * light * inLens;
+ return vec4(clamp(finished, vec3(0.0), vec3(1.0)), 1.0);
+}
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/mine.part b/dash-apps/contest/prag-designs-monte-carlo/src/mine.part
new file mode 100644
index 00000000..c575594d
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/mine.part
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/rive.yaml b/dash-apps/contest/prag-designs-monte-carlo/src/rive.yaml
new file mode 100644
index 00000000..d2db0107
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/rive.yaml
@@ -0,0 +1,9 @@
+name: monte-carlo
+main: Dash
+artboard:
+ background: "#000000"
+output:
+ dir: ..
+logs:
+ file: build/rive.log
+ problems: build/problems.log
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/scene.rml b/dash-apps/contest/prag-designs-monte-carlo/src/scene.rml
new file mode 100644
index 00000000..7825fe79
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/scene.rml
@@ -0,0 +1,740 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/v3.part b/dash-apps/contest/prag-designs-monte-carlo/src/v3.part
new file mode 100644
index 00000000..aa41c569
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/v3.part
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/v3.py b/dash-apps/contest/prag-designs-monte-carlo/src/v3.py
new file mode 100644
index 00000000..63a38e00
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/v3.py
@@ -0,0 +1,539 @@
+"""Monte Carlo: a sports-car lens cluster, drawn from a face-on reference.
+
+Every position is measured on the reference (2172 x 724) and mapped onto the 2280 x 1080 artboard by P():
+scale 2280 / 2172, centred vertically. On top of it: a near-black cool
+face with ONE crimson glow behind the speed, thin luminous graphics, a faint hex grid that fades out,
+narrow peripheral gauges, a quiet bottom tab, the power fill / track / redline as one ribbon.
+Type: Saira Italic, variable, pushed to 125 % width for the extended oblique look of the target.
+Draw order: the FIRST sibling draws on TOP; node()/stack() take paint order, bottom first.
+"""
+import math
+import os
+import re
+from gen import (nid, bind, fill, glow, grad_fill, stroke, grad_stroke, trim, shape, node, rect, poly, ellipse,
+ SM, OPACITY, TEXT, TRIM_START, TRIM_END, SCALE_X, SCALE_Y, VM, fx_layer)
+
+VM.update({"introDip": "1:87", "introEdge": "1:88", "introTell": "1:89", "introScale": "1:90", "displayPower": "1:91"})
+
+
+def fade(prop, kids, name):
+ """A group that the start-up fades in through one view-model number (1 = finished)."""
+ return node(name, kids, 0, 0, 0, bind(prop, OPACITY))
+
+W, H = 2280, 1080
+K = 2280 / 2172
+OY = (1080 - 724 * K) / 2
+SAIRA = "2:907"
+WHITE, LIT, UNLIT = "FFF6F8F5", "FFEEF0ED", "FF25262C"
+FILL = [("FFFFC531", 0), ("FFF8901A", 0.3), ("FFF36E00", 0.5), ("FFD8380A", 0.75), ("FFC52800", 1)]
+
+
+def P(x, y):
+ return x * K, OY + y * K
+
+
+def S(v):
+ return v * K
+
+
+def radial(stops, r):
+ s = "".join(f'' for c, p in stops)
+ return f'{s}'
+
+
+def T(name, runs, x, y, size, color=WHITE, w=600, wght=700, wdth=125, align="center", ox=0.5, spacing=0, binds="", sx=1.0):
+ """Saira Italic text at a width and weight. runs: (text, bind) or (text, bind, size)."""
+ styles, rs = {}, []
+ for r in runs:
+ sz = r[2] if len(r) > 2 else size
+ if sz not in styles:
+ styles[sz] = nid()
+ rs.append(f'{r[1]}')
+ paints = "".join(
+ f''
+ f'{fill(color)}'
+ f''
+ f''
+ for sz, i in styles.items())
+ hh = int(size * 1.5)
+ if sx != 1.0: # stretch wide about the text's own centre, still vector
+ inner = T(name, runs, 0, 0, size, color, w, wght, wdth, align, ox, spacing)
+ holder = f'{inner}'
+ return node(name + " Holder", [holder], 0, 0, 0, binds) if binds else holder
+ t = (f'{paints}{"".join(rs)}')
+ return node(name + " Holder", [t], 0, 0, 0, binds) if binds else t
+
+
+def catmull(pts, n=12):
+ out = []
+ for i in range(len(pts) - 1):
+ p0, p1, p2, p3 = pts[max(i - 1, 0)], pts[i], pts[i + 1], pts[min(i + 2, len(pts) - 1)]
+ for j in range(n):
+ t = j / n
+ t2, t3 = t * t, t * t * t
+ out.append(tuple(0.5 * (2 * p1[a] + (-p0[a] + p2[a]) * t + (2 * p0[a] - 5 * p1[a] + 4 * p2[a] - p3[a]) * t2
+ + (-p0[a] + 3 * p1[a] - 3 * p2[a] + p3[a]) * t3) for a in (0, 1)))
+ out.append(pts[-1])
+ return out
+
+
+# ---------------------------------------------------------------- the lens (measured on target 2, symmetric about x 1086)
+TOP_L = [(25, 372), (30, 300), (40, 268), (80, 225), (150, 187), (300, 144), (600, 100), (1086, 78)]
+BOT_L = [(25, 400), (33, 500), (40, 523), (80, 565), (150, 593), (300, 616), (600, 633), (1086, 635)]
+
+
+def lens_pts():
+ top = catmull(TOP_L + [(2172 - x, y) for x, y in TOP_L[::-1][1:]])
+ bot = catmull(BOT_L + [(2172 - x, y) for x, y in BOT_L[::-1][1:]])
+ return [P(x, y) for x, y in top + bot[::-1]]
+
+
+def lens():
+ pts = lens_pts()
+ k = [fade("introMain", [shape("Lens Face", poly(pts) + grad_fill("LinearGradient", [("FF070A10", 0), ("FF04060A", 0.5), ("FF030407", 1)], sx=0, sy=P(0, 78)[1], ex=0, ey=P(0, 635)[1])),
+ shape("Lens Inner", poly(pts) + fill("CC000000", glow(S(30), 0, 0, inner=True)))], "Lens Body"),
+ shape("Lens Top Edge", poly(pts[:len(pts) // 2], False) + grad_stroke(1.5, [("00303A44", 0), ("40303A44", 0.3), ("55394651", 0.5), ("40303A44", 0.7), ("00303A44", 1)], 0, 0, W, 0,
+ trim(0, 1, bind("introEdge", TRIM_START, "9:100") + bind("introEdge", TRIM_END, "9:101"))))]
+ return node("Lens", k)
+
+
+# ---------------------------------------------------------------- the power ribbon
+BX0, BX1, BMID = 262.0, 1905.0, 1083.5
+
+
+def base_y(x): # the baseline the fill block sits on (target px)
+ u = (x - BMID) / (BX1 - BMID)
+ return 136 + 72 * u * u
+
+
+def bx(v):
+ return BX0 + (BX1 - BX0) * v / 10
+
+
+def ribbon(x0, x1, dy=0.0, n=90):
+ return [P(x0 + (x1 - x0) * i / n, base_y(x0 + (x1 - x0) * i / n) + dy) for i in range(n + 1)]
+
+
+def hexes(cx, cy, size, rx, ry, rings, color, alpha, falloff, width=1.0):
+ buckets = [[] for _ in range(rings)]
+ seen = set()
+ wv = math.sqrt(3) * size
+ for r_ in range(-int(ry / (1.5 * size)) - 2, int(ry / (1.5 * size)) + 3):
+ for c_ in range(-int(rx / wv) - 2, int(rx / wv) + 3):
+ hx = cx + (c_ + (0.5 if r_ % 2 else 0)) * wv
+ hy = cy + r_ * 1.5 * size
+ d = math.hypot((hx - cx) / rx, (hy - cy) / ry)
+ if d > 1:
+ continue
+ vertices = [(hx + size * math.cos(math.radians(30 + 60 * j)), hy + size * math.sin(math.radians(30 + 60 * j))) for j in range(6)]
+ for j in range(6): # every wall once, so neighbours share it
+ a, z = vertices[j], vertices[(j + 1) % 6]
+ key = tuple(sorted(((round(a[0], 3), round(a[1], 3)), (round(z[0], 3), round(z[1], 3)))))
+ if key in seen:
+ continue
+ seen.add(key)
+ ed = math.hypot(((a[0] + z[0]) / 2 - cx) / rx, ((a[1] + z[1]) / 2 - cy) / ry)
+ if ed <= 1:
+ buckets[min(rings - 1, int(ed * rings))].append(poly([a, z], False))
+ out = []
+ for b, paths in enumerate(buckets):
+ if paths:
+ a = int(255 * alpha * (1 - b / rings) ** falloff)
+ out.append(f'{"".join(paths)}{stroke(f"{a:02X}" + color[2:], width)}')
+ return out
+
+
+PATTERN = os.environ.get("V3_PATTERN", "carbon") # the texture inside the speed glow: carbon weave (hex was the original)
+
+
+def faded(segs, cx, cy, rx, ry, rings, color, alpha, falloff, width, name, closed=False):
+ """Bucket line pieces by their distance from the glow centre so the texture fades out the same way the hex grid does."""
+ buckets = [[] for _ in range(rings)]
+ for pts in segs:
+ mx, my = sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)
+ d = math.hypot((mx - cx) / rx, (my - cy) / ry)
+ if d <= 1:
+ buckets[min(rings - 1, int(d * rings))].append(poly(pts, closed))
+ out = []
+ for b, paths in enumerate(buckets):
+ if paths:
+ a = int(255 * alpha * (1 - b / rings) ** falloff)
+ out.append(f'{"".join(paths)}{stroke(f"{a:02X}" + color[2:], width)}')
+ return out
+
+
+def pieces(a, b, step):
+ """Cut a straight line into short pieces, so each piece fades by its own distance."""
+ n = max(1, int(math.hypot(b[0] - a[0], b[1] - a[1]) / step))
+ return [[(a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n), (a[0] + (b[0] - a[0]) * (i + 1) / n, a[1] + (b[1] - a[1]) * (i + 1) / n)] for i in range(n)]
+
+
+def texture(cx, cy, rx, ry, color):
+ if PATTERN == "hex":
+ return hexes(cx, cy, S(19), rx, ry, 64, color, 0.38, 1.15, S(1.10))
+ if PATTERN == "none":
+ return []
+ segs = []
+ if PATTERN == "carbon": # twill weave: short diagonals, alternating direction per cell
+ c = S(13)
+ for j in range(-int(ry / c) - 1, int(ry / c) + 2):
+ for i in range(-int(rx / c) - 1, int(rx / c) + 2):
+ x, y = cx + i * c, cy + j * c
+ if (i + j) % 2:
+ segs += [[(x + c * 0.15, y + c * 0.85), (x + c * 0.85, y + c * 0.15)]]
+ else:
+ segs += [[(x + c * 0.15, y + c * 0.15), (x + c * 0.85, y + c * 0.85)]]
+ return faded(segs, cx, cy, rx, ry, 48, color, 0.30, 1.1, S(1.4), "Weave")
+ if PATTERN == "rings": # concentric lens rings around the number
+ for k in range(1, 40):
+ r = k * S(11)
+ pts = [(cx + r * (rx / ry) * math.cos(t * math.pi / 48), cy + r * math.sin(t * math.pi / 48)) for t in range(97)]
+ segs += [pts[i:i + 3] for i in range(0, 96, 2)]
+ return faded(segs, cx, cy, rx, ry, 48, color, 0.30, 1.2, S(1.1), "Ring")
+ if PATTERN == "streaks": # pinstripes at the numerals' italic slant, like motion
+ sl = math.tan(math.radians(14))
+ g = S(15)
+ for i in range(-int((rx + ry) / g) - 2, int((rx + ry) / g) + 3):
+ x0 = cx + i * g
+ segs += pieces((x0 - ry * sl, cy + ry), (x0 + ry * sl, cy - ry), S(12))
+ return faded(segs, cx, cy, rx, ry, 48, color, 0.28, 1.1, S(1.2), "Streak")
+ if PATTERN == "dots": # dot matrix: tiny squares with round corners, like an LED field
+ g = S(17)
+ out_b = [[] for _ in range(48)]
+ for j in range(-int(ry / g) - 1, int(ry / g) + 2):
+ for i in range(-int(rx / g) - 1, int(rx / g) + 2):
+ x, y = cx + i * g + (g / 2 if j % 2 else 0), cy + j * g
+ d = math.hypot((x - cx) / rx, (y - cy) / ry)
+ if d <= 1:
+ out_b[min(47, int(d * 48))].append(rect(S(2.8), S(2.8), x, y, S(1.4)))
+ out = []
+ for b, paths in enumerate(out_b):
+ if paths:
+ a = int(255 * 0.50 * (1 - b / 48) ** 1.1)
+ out.append(f'{"".join(paths)}{fill(f"{a:02X}" + color[2:])}')
+ return out
+ raise SystemExit("unknown V3_PATTERN " + PATTERN)
+
+
+def power():
+ k = []
+ TH = S(42) # ribbon thickness
+ mid = poly(ribbon(BX0, BX1, -21), False) # the ribbon's centre line, 21 above the baseline
+ k.append(shape("Ribbon Track", mid + stroke("FF0B0A0E", TH)))
+ # regen: the same ribbon carried a short way left of 0
+ rg = poly(ribbon(BX0, 200, -21, 12), False)
+ k.append(shape("Regen Fill", rg + stroke("FF71BDB2", TH, trim(0, 0, bind("displayPower", TRIM_END, "9:132")))))
+ # redline 8..10: the same ribbon, red, a fine hex texture inside
+ upper, lower = [], []
+ for i in range(61):
+ x = bx(8) + (BX1 - bx(8)) * i / 60
+ slope = 144 * (x - BMID) / (BX1 - BMID) ** 2
+ inv = 1 / math.sqrt(1 + slope * slope)
+ nx_, ny_ = -slope * inv, inv
+ y = base_y(x) - 21
+ upper.append(P(x - 21 * nx_, y - 21 * ny_))
+ lower.append(P(x + 21 * nx_, y + 21 * ny_))
+ rz = upper[::-1] + lower
+ rid = nid()
+ k.append(f'{poly(rz)}'
+ f'{grad_fill("LinearGradient", [("2EB20B08", 0), ("4DDA170C", 1)], sx=P(bx(8), 0)[0], sy=0, ex=P(BX1, 0)[0], ey=0)}')
+ hz = "".join(hexes(*P(bx(9), base_y(bx(9)) - 21), S(11), S(200), S(40), 1, "FFFF5335", 0.20, 0, S(1.0)))
+ k.append(hz.replace("", f''))
+ m0, m1 = P(bx(8), base_y(bx(8)) - 46), P(bx(8), base_y(bx(8)) + 4)
+ k.append(shape("Redline Mark", poly([m0, m1], False) + stroke("FFFF2A1A", S(4))))
+ # the zone is marked, not filled: a thin red edge along its top; the live fill turns it red when it gets there
+ k.append(shape("Redline Edge", poly(upper, False) + stroke("CCFF2A1A", S(2.5))))
+ # the live fill, a soft bloom, and a bright sliver at its head
+ k.append(shape("Fill", mid + grad_stroke(TH, FILL, P(BX0, 0)[0], 0, P(BX1, 0)[0], 0, trim(0, 0, bind("displayPower", TRIM_END, "9:130")))))
+ k.append(shape("Fill Head", mid + stroke("FFFFBB52", TH + S(2), trim(0, 0, bind("displayPower", TRIM_START, "9:131") + bind("displayPower", TRIM_END, "9:130")))))
+ # the baseline: a thin luminous line under the ribbon
+ base = poly(ribbon(BX0 - 4, BX1 + 4, 0), False)
+ k.append(shape("Baseline", base + grad_stroke(1.6, [("88B8B4BA", 0), ("B0D8D4DA", 0.5), ("88B8B4BA", 0.85), ("CCFF3A2A", 1)], P(BX0, 0)[0], 0, P(BX1, 0)[0], 0)))
+ # ticks hang under the baseline, numbers under the majors
+ for i in range(21):
+ v = i / 2
+ x = bx(v)
+ y = base_y(x)
+ slope = 2 * 72 * (x - BMID) / (BX1 - BMID) ** 2
+ tilt = math.atan(slope)
+ major = i % 2 == 0
+ L = 22 if major else 10
+ px, py = P(x, y)
+ k.append(shape(f"Tick {i}", rect(S(3.7) if major else S(2.2), S(L), 0, S(L) / 2, 1) + fill(WHITE if major else "CCD8D8DC"), px, py + 1, tilt))
+ if major:
+ nx, ny = P(x + 2, y + 50)
+ k.append(T(f"Scale {i // 2}", [(str(i // 2), "")], nx, ny, S(40), WHITE, w=140, wght=660, sx=1.15,
+ binds=bind("introScale", OPACITY)))
+ cx, cy = P(1086, 230)
+ k.append(T("Scale Caption", [("x10 % POWER", "")], cx, cy, S(19), WHITE, w=300, wght=700, spacing=1, binds=bind("introBars", OPACITY)))
+ return node("Power", k, 0, 0, 0, bind("introMain", OPACITY))
+
+
+# ---------------------------------------------------------------- centre: glow, hex, speed, divider, dip
+def cubic_points(a, b, c, d, n=48):
+ return [tuple((1 - t) ** 3 * a[k] + 3 * (1 - t) ** 2 * t * b[k] + 3 * (1 - t) * t * t * c[k] + t ** 3 * d[k] for k in (0, 1))
+ for t in (i / n for i in range(n + 1))]
+
+
+def divider():
+ """Wing line at y 430, a shoulder with flat tangents at both ends, the dip floor exactly flat at y 520."""
+ left = [(25, 432), (600, 430), (650, 430)]
+ left += cubic_points((650, 430), (735, 430), (825, 520), (880, 520))[1:]
+ right = [(2172 - x, y) for x, y in left[::-1]]
+ return [P(x, y) for x, y in left + [(1292, 520)] + right[1:]]
+
+
+def centre():
+ k = []
+ lid = nid()
+ k.append(f'{poly(lens_pts())}')
+ clip = f''
+
+ def clipped(s):
+ i = s.rindex("")
+ return s[:i] + clip + s[i:]
+
+ def oval(name, cx, cy, rx, ry, stops, binds=""):
+ sh = clipped(shape(name, ellipse(2 * rx) + radial(stops, rx)))
+ return f'{binds}{sh}'
+
+ gx, gy = P(1086, 365)
+ glow_k = []
+ k0 = k
+ k = glow_k
+ # one crimson field behind the speed: dark red outside, a hotter heart behind the numerals
+ k.append(oval("Glow Outer", gx, gy + S(40), S(600), S(320), [("80550607", 0), ("40550607", 0.5), ("00550607", 1)]))
+ # red light creeping in at the lower corners of the glass
+ for sx_ in (-1, 1):
+ cx_, cy_ = P(1086 + sx_ * 1000, 560)
+ k.append(oval(f"Edge Red {sx_}", cx_, cy_, S(260), S(170), [("40600808", 0), ("1A600808", 0.5), ("00600808", 1)]))
+ k.append(oval("Glow Core", gx, gy, S(340), S(225), [("D9B8140C", 0), ("A6930D0A", 0.36), ("38500608", 0.72), ("00500608", 1)]))
+ k.append(oval("Glow Heart", gx, gy - S(5), S(170), S(110), [("33F53C14", 0), ("12E72A10", 0.6), ("00E72A10", 1)]))
+ # the faint hex grid inside the glow
+ hx = texture(gx, gy + S(10), S(410), S(215), "FFFF4B24")
+ k += [clipped(h) for h in hx]
+ k = k0
+ k.append(f'{bind("introSide", OPACITY)}'
+ f'{node("Bloom Origin", glow_k, -gx, -gy)}')
+ # the divider and its lit dip
+ dv = divider()
+ dip0, dip1 = P(880, 520)[0], P(1292, 520)[0]
+ k.append(shape("Divider", poly(dv, False) + grad_stroke(S(4.0), [("00303038", 0), ("B0303038", 0.1), ("C0383038", 0.28), ("FFC0442A", 0.36),
+ ("FFD8502C", 0.5), ("FFC0442A", 0.64), ("C0383038", 0.72), ("B0303038", 0.9), ("00303038", 1)],
+ 0, 0, W, 0, trim(0, 1, bind("introDip", TRIM_START, "9:100") + bind("introDip", TRIM_END, "9:101")))))
+ dy = P(0, 520)[1]
+ dip_k = []
+ k0 = k
+ k = dip_k
+ k.append(oval("Dip Spill", W / 2, dy - S(32), S(285), S(95), [("78FF9A30", 0), ("30FF7A20", 0.5), ("00FF6020", 1)]))
+ k.append(shape("Dip Halo Wide", poly([(dip0, dy), (dip1, dy)], False) + grad_stroke(S(26), [("00FF9A2B", 0), ("40FF9A2B", 0.2), ("90FFA52B", 0.5), ("40FF9A2B", 0.8), ("00FF9A2B", 1)], dip0, 0, dip1, 0, glow(S(7)))))
+ k.append(shape("Dip Halo", poly([(dip0, dy), (dip1, dy)], False) + grad_stroke(S(8), [("00FFB52B", 0), ("99FFB52B", 0.15), ("E6FFB52B", 0.5), ("99FFB52B", 0.85), ("00FFB52B", 1)], dip0, 0, dip1, 0, glow(S(2)))))
+ k.append(shape("Dip Core", poly([(dip0, dy), (dip1, dy)], False) + grad_stroke(S(4.2), [("00FFE6A1", 0), ("CCFFE6A1", 0.14), ("FFFFFBEA", 0.5), ("CCFFE6A1", 0.86), ("00FFE6A1", 1)], dip0, 0, dip1, 0)))
+ k = k0
+ k.append(node("Dip", dip_k, 0, 0, 0, bind("introDip", OPACITY)))
+ # the speed (it races the sweep), its unit, gear, mode
+ sx, sy = P(1090, 362)
+ k.append(T("Speed", [("86", bind("speed", TEXT, "2:1"))], sx, sy, S(156), WHITE, w=760, wght=640, sx=1.18, binds=bind("introSide", OPACITY)))
+ labels = []
+ k0 = k
+ k = labels
+ ux, uy = P(1086, 463)
+ k.append(T("Speed Unit", [("KM/H", "")], ux, uy, S(26), WHITE, w=200, wght=700, sx=1.12))
+ gx2, gy2 = P(490, 368)
+ for g_, conv in (("P", "2:11"), ("R", "2:12"), ("N", "2:13"), ("D", "2:14")):
+ k.append(T(f"Gear {g_}", [(g_, "")], gx2, gy2, S(48), WHITE, w=140, wght=820, sx=1.15, binds=bind("gear", OPACITY, conv)))
+ mx, my = P(1632, 368)
+ # the outgoing word goes at once (state gate); only the incoming word fades in, so they never overlap
+ k.append(node("Assist State Gate", [T("Mode Assist", [("ASSIST", "")], mx, my, S(35), WHITE, w=340, wght=720, sx=1.12, binds=bind("adas", OPACITY, "9:145"))], 0, 0, 0, bind("adas", OPACITY, "2:41")))
+ k.append(node("Manual State Gate", [T("Mode Manual", [("MANUAL", "")], mx, my, S(35), "FFB8B6BC", w=340, wght=680, sx=1.12, binds=bind("adas", OPACITY, "9:146"))], 0, 0, 0, bind("adas", OPACITY, "2:43")))
+ # posted limit: a quiet figure and caption between the speed and the mode
+ lx, ly = P(1412, 318)
+ k.append(node("Limit", [T("Limit Num", [("80", bind("limit", TEXT, "2:1"))], lx, ly, S(30), WHITE, w=120, wght=700),
+ T("Limit Cap", [("LIMIT", "")], lx, ly + S(28), S(12), "FFB8B6BC", w=120, wght=700, spacing=1)],
+ 0, 0, 0, bind("limit", OPACITY, "2:5")))
+ k = k0
+ k.append(node("Labels", labels, 0, 0, 0, bind("introBars", OPACITY)))
+ # traffic light: a small native lamp under the limit, not a wash over the speed field
+ tlx, tly = P(1412, 390)
+ for nm, c, conv in (("Red", "FF3B30", "2:11"), ("Green", "32D583", "2:12"), ("Yellow", "FFC247", "2:13")):
+ lamp = [shape(nm + " Signal Spill", ellipse(S(32)) + fill("26" + c, glow(S(9)))), shape(nm + " Signal", ellipse(S(12)) + fill("FF" + c))]
+ k.append(node(nm + " Traffic", lamp, tlx, tly, 0, bind("light", OPACITY, conv)))
+ # turn telltales: hidden unless the lamp is lit (and lit once together at start-up, as a bulb check)
+ for nm, x, s, prop in (("Blink L", 500, -1, "lblink"), ("Blink R", 1672, 1, "rblink")):
+ pts = [(-s * 13, -6), (s * 1, -6), (s * 1, -14), (s * 15, 0), (s * 1, 14), (s * 1, 6), (-s * 13, 6)]
+ pts = [(S(a), S(b)) for a, b in pts]
+ cx2, cy2 = P(x, 311)
+ lit_k = [shape(f"{nm} Glow", poly(pts) + fill("88FFB020", glow(S(8)))), shape(f"{nm} Lit", poly(pts) + fill("FFFFB020"))]
+ k.append(node(nm, lit_k, cx2, cy2, 0, bind(prop, OPACITY, "8:68")))
+ return node("Centre", k)
+
+
+# ---------------------------------------------------------------- side gauges (target left side; the right mirrors)
+def rail(off):
+ """One rail of the left gauge, offset along the true normal so the two rails stay parallel round the turn."""
+ p = cubic_points((57, 312), (70, 380), (85, 455), (104, 518))
+ p += cubic_points((104, 518), (114, 551), (133, 557), (160, 557))[1:]
+ p += [(186, 557)]
+ out = []
+ for i, (x, y) in enumerate(p):
+ a, b = p[max(0, i - 1)], p[min(len(p) - 1, i + 1)]
+ dx, dy = b[0] - a[0], b[1] - a[1]
+ ln = math.hypot(dx, dy) or 1
+ out.append((x + off * dy / ln, y - off * dx / ln))
+ return out
+
+
+def along(pts, k):
+ seg = [math.dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
+ total = sum(seg)
+ out, acc, j = [], 0.0, 0
+ for i in range(k + 1):
+ want = total * i / k
+ while j < len(seg) - 1 and acc + seg[j] < want:
+ acc += seg[j]
+ j += 1
+ f = (want - acc) / seg[j] if seg[j] else 0
+ (x0, y0), (x1, y1) = pts[j], pts[j + 1]
+ out.append((x0 + (x1 - x0) * f, y0 + (y1 - y0) * f))
+ return out
+
+
+def gauge(name, side, prop, convs, top_lbl, bot_lbl, icon, red_at):
+ m = (lambda p: P(*p)) if side < 0 else (lambda p: P(2172 - p[0], p[1]))
+ k = []
+ off_k, data_k, sweep_k = [], [], []
+ N = 8
+ for lane, off in (("Outer", 0.0), ("Inner", -14.0)):
+ pts = rail(-off)[::-1] # bottom first
+ marks = along(pts, N * 10)
+ for i in range(N):
+ seg = [m(p) for p in marks[i * 10: i * 10 + 9]] # a short gap between segments
+ red = (red_at == "bottom" and i == 0) or (red_at == "top" and i == N - 1)
+ lit = "FFFF3025" if red else LIT
+ off_k.append(shape(f"{name} {lane} {i} Off", poly(seg, False) + stroke(UNLIT, S(5), "", "round")))
+ data_k.append(shape(f"{name} {lane} {i}", poly(seg, False) + stroke(lit, S(5), "", "round"), 0, 0, 0, bind(prop, OPACITY, convs[i])))
+ sweep_k.append(shape(f"{name} {lane} {i} Sweep", poly(seg, False) + stroke(lit, S(5), "", "round"), 0, 0, 0, bind("introSweep", OPACITY, f"9:{90 + i}")))
+ k.append(node(f"{name} Rails", off_k, 0, 0, 0, bind("introMain", OPACITY)))
+ k.append(node(f"{name} Live", data_k, 0, 0, 0, bind("introBars", OPACITY)))
+ k.append(node(f"{name} Sweep", sweep_k))
+ tx, ty = m((86, 300))
+ bx_, by_ = m((165, 515))
+ k.append(T(f"{name} Top", [(top_lbl, "")], tx, ty, S(18), WHITE, w=60, wght=760))
+ k.append(T(f"{name} Bottom", [(bot_lbl, "")], bx_, by_, S(20), WHITE, w=60, wght=760))
+ ix, iy = m((132, 390))
+ k.append(node(f"{name} Icon", icon, ix, iy))
+ return node(name, [k[0], k[1], k[2], node(f"{name} Marks", k[3:], 0, 0, 0, bind("introBars", OPACITY))])
+
+
+def temp_icon():
+ s = S(1)
+ return [shape("Therm Stem", rect(6 * s, 26 * s, 0, -6 * s, 3 * s) + stroke(WHITE, 2.2 * s)),
+ shape("Therm Bulb", rect(12 * s, 12 * s, 0, 10 * s, 6 * s) + fill(WHITE)),
+ shape("Therm Marks", poly([(4 * s, -14 * s), (10 * s, -14 * s)], False) + poly([(4 * s, -8 * s), (10 * s, -8 * s)], False) + stroke(WHITE, 2 * s)),
+ shape("Therm Waves", poly([(-16 * s, 20 * s), (-10 * s, 18 * s), (-4 * s, 20 * s), (2 * s, 18 * s), (8 * s, 20 * s), (14 * s, 18 * s)], False) + stroke(WHITE, 2 * s))]
+
+
+def battery_icon():
+ s = S(1)
+ return [shape("Bat Body", rect(30 * s, 20 * s, 0, 0, 3 * s) + stroke(WHITE, 2.2 * s)),
+ shape("Bat Tips", rect(5 * s, 4 * s, -8 * s, -12 * s, 1) + fill(WHITE) + ""),
+ shape("Bat Tip R", rect(5 * s, 4 * s, 8 * s, -12 * s, 1) + fill(WHITE)),
+ shape("Bat Plus", poly([(-11 * s, 0), (-5 * s, 0)], False) + poly([(-8 * s, -3 * s), (-8 * s, 3 * s)], False) + poly([(5 * s, 0), (11 * s, 0)], False) + stroke(WHITE, 1.8 * s))]
+
+
+# ---------------------------------------------------------------- the bottom tab
+def tab():
+ L = [(475, 628), (545, 576), (1086, 571)]
+ pts = catmull([(475, 628), (548, 576), (800, 572), (1086, 571), (1372, 572), (1624, 576), (1697, 628)], 8)
+ pts = [P(x, y) for x, y in pts]
+ k = [shape("Tab Fill", poly(pts) + fill("40080C12")),
+ shape("Tab Line", poly(pts, False) + grad_stroke(1.4, [("40394149", 0), ("90606670", 0.5), ("40394149", 1)], 0, 0, W, 0))]
+ for nm, x, runs in (("Clock", 608, [("07", bind("time", TEXT, "2:28")), (":", ""), ("25", bind("time", TEXT, "2:30"))]),
+ ("Odo", 1085, [("042180", bind("odo", TEXT, "2:1")), (" KM", "", S(13))]),
+ ("Charge", 1572, [("69", bind("energy", TEXT, "2:17")), ("%", "", S(14))])):
+ cx, cy = P(x, 608)
+ k.append(T(nm, runs, cx, cy, S(27), "FFE4E8EA", w=320, wght=640, sx=1.12))
+ return node("Tab", k, 0, 0, 0, bind("introBars", OPACITY))
+
+
+# ---------------------------------------------------------------- blind spots light the lens tip on that side
+def blind():
+ """Blind spots light the lens tip on that side: a soft amber glow, red when urgent (it replaces amber).
+ A radial fill clipped to the glass; a feathered tip polygon does not render in Rive."""
+ k = []
+ lid = nid()
+ k.append(f'{poly(lens_pts())}')
+ clip = f''
+
+ def tip_glow(name, cx, cy, c, a):
+ r = S(210)
+ sh = shape(name, ellipse(2 * r) + radial([(a + c, 0), (a[:1] + "0" + c if False else "40" + c, 0.45), ("00" + c, 1)], r))
+ i = sh.rindex("")
+ return f'{sh[:i] + clip + sh[i:]}'
+
+ for nm, side, prop, urg in (("Blind L", -1, "lbs", "9:143"), ("Blind R", 1, "rbs", "9:144")):
+ cx, cy = P(1086 + side * 1050, 395)
+ k.append(node(nm, [tip_glow(f"{nm} Amber", cx, cy, "FFB020", "AA")], 0, 0, 0, bind(prop, OPACITY, "9:142")))
+ k.append(node(f"{nm} Urgent", [tip_glow(f"{nm} Red", cx, cy, "FF3025", "CC")], 0, 0, 0, bind(prop, OPACITY, urg)))
+ return node("Blind Spots", k)
+
+
+def instrument():
+ temp = gauge("Temp", -1, "tmax", [f"9:{60 + i}" for i in range(8)], "H", "C", temp_icon(), "top")
+ batt = gauge("Battery", 1, "energy", [f"9:{50 + i}" for i in range(8)], "F", "E", battery_icon(), "bottom")
+ return node("Instrument", [lens(), centre(), power(), temp, batt, tab(), blind()])
+
+
+def scene():
+ # the native copy lives in its own 2280 x 1080 artboard, shown through a nested leaf with fit contain:
+ # centred on a 19.5:9 phone, shrunk as a whole on a 4:3 tablet. Hidden once the GPU layer runs.
+ sid = nid()
+ native = (f'{bind("fx", OPACITY, "2:43")}'
+ f''
+ f''
+ f'')
+ native_ab = f'''
+
+ {instrument()}
+ {SM.format(s=" N", a="6:24", n="Native", m="6:25", l="6:26", st="6:27")}
+ '''
+ dash = f'''
+
+
+ {fx_layer()}
+ {native}
+ {SM.format(s="", a="4:1", n="Dash", m="5:1", l="5:10", st="5:11")}
+ '''
+ inst = f'''
+
+
+ {instrument()}
+ {SM.format(s=" I", a="6:4", n="Instrument", m="6:5", l="6:6", st="6:7")}
+ '''
+ return f'''
+
+{dash}
+{inst}
+{native_ab}
+
+'''
+
+
+def merged(doc):
+ parts = []
+ for f in ("viewmodel.part", "converters.part", "mine.part", "v3.part"):
+ s = open(f).read()
+ s = re.sub(r"]*>", "", s, count=1)
+ parts.append(s[:s.rindex("")])
+ return doc.replace("", "\n".join(parts) + "\n")
+
+
+if __name__ == "__main__":
+ open("scene.rml", "w").write(merged(scene()))
+ print("scene.rml written")
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/v3_fx.luau b/dash-apps/contest/prag-designs-monte-carlo/src/v3_fx.luau
new file mode 100644
index 00000000..fc8f5ac0
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/v3_fx.luau
@@ -0,0 +1,265 @@
+-- v3 GPU layer. Renders the native Instrument artboard (bound to DashPilot's live view model) into a
+-- texture every frame and finishes it in lensfx.wgsl: bloom, a spark at the head of the power fill,
+-- redline heat, the start-up ripple and the light at the top of the gauge sweep.
+-- It also runs the start-up, like a real car's ignition: the glass wakes and its edge traces out from
+-- the centre, the gold dip ignites, the crimson glow blooms, every tell-tale flashes once as a bulb
+-- check, then the full gauge sweep (bar to the redline and back, the speed racing 0 to 200 and back,
+-- both rails up and down), and the labels settle in. The sweep writes view-model numbers that the
+-- native vectors are bound to, so the text stays sharp.
+-- If this script cannot run, nothing here draws and the native copy in Dash stays visible.
+type Fx = {
+ ui: Input>,
+ inst: Artboard?,
+ vm: ViewModel?,
+ full: Canvas?, small: Canvas?, out: GPUCanvas?,
+ pipe: GPUPipeline?, group: GPUBindGroup?, ubo: GPUBuffer?, buf: buffer,
+ samp: GPUSampler?, img: ImageSampler,
+ w: number, h: number, k: number, t: number, ready: boolean, scale: number, sizedW: number,
+ pow: number, regen: number, red: number, green: number, yellow: number, lOn: number, rOn: number,
+ over: number, introDone: boolean, sweep: number, scaleSeen: number, peak: number, introT: number,
+ display: number, shown: boolean, restored: boolean,
+}
+
+local MAXTEX = 8192 -- the largest texture side we ask for
+local K = 2280 / 2172 -- v3.py maps target-2 pixels onto the artboard with this scale
+local OY = (1080 - 724 * K) / 2
+local GLOW_X, GLOW_Y = 1086 * K, OY + 365 * K
+
+local function num(vm: ViewModel, name: string): number
+ local p = vm:getNumber(name)
+ if p then return p.value end
+ return 0
+end
+
+local function setNum(vm: ViewModel, name: string, v: number)
+ local p = vm:getNumber(name)
+ if p then p.value = v end
+end
+
+local function ease(cur: number, target: number, rate: number, dt: number): number
+ local nextValue = cur + (target - cur) * (1 - math.exp(-rate * dt))
+ return math.abs(nextValue - target) < 0.0001 and target or nextValue -- a held value truly settles
+end
+
+local function smoother(x: number): number -- zero speed and acceleration at both ends
+ local z = math.clamp(x, 0, 1)
+ return z * z * z * (z * (z * 6 - 15) + 10)
+end
+
+local function easeOut(x: number): number
+ local c = math.clamp(x, 0, 1)
+ return 1 - (1 - c) ^ 3
+end
+
+
+local INTRO = { 'introMain', 'introEdge', 'introDip', 'introSide', 'introBars', 'introScale' }
+
+-- the start-up: one overlapping event, about 1.85 s. The glass and its edge wake, the gold dip ignites from
+-- the centre, the crimson field fades up in place, then the bar sweeps to the redline and back while the
+-- rails follow. Speed stays the live value throughout. A car that is already moving skips straight to the end.
+local S = 1.25 -- stretch over the base 1.48 s choreography
+local function intro(self: Fx, vm: ViewModel)
+ if self.introDone then return end
+ local T = self.t / S
+ if math.abs(num(vm, 'egoSpeed')) > 0.5 then
+ T = 1.48
+ local live = math.clamp(num(vm, 'powerLevel') / 100, -1, 1)
+ self.pow, self.regen = math.max(live, 0), math.max(-live, 0)
+ self.display = live
+ setNum(vm, 'displayPowerPct', live * 100)
+ end
+ self.introT = T
+ setNum(vm, 'introMain', easeOut(T / 0.24))
+ setNum(vm, 'introEdge', easeOut((T - 0.04) / 0.42))
+ setNum(vm, 'introDip', easeOut((T - 0.12) / 0.30))
+ setNum(vm, 'introSide', easeOut((T - 0.20) / 0.32))
+ setNum(vm, 'introBars', easeOut((T - 0.28) / 0.24))
+ setNum(vm, 'introScale', easeOut((T - 0.20) / 0.30))
+ setNum(vm, 'introTell', 0)
+ local up = smoother((T - 0.34) / 0.54)
+ local down = smoother((T - 0.88) / 0.60)
+ self.sweep = up * (1 - down)
+ setNum(vm, 'introSweep', self.sweep)
+ if T >= 1.48 then
+ self.introDone = true
+ self.sweep = 0
+ for _, n in INTRO do setNum(vm, n, 1) end
+ setNum(vm, 'introSweep', 0)
+ end
+end
+
+function init(self: Fx, context: Context): boolean
+ if self.ready then return true end
+ local vm = context:viewModel()
+ if vm == nil then return true end -- init runs twice; the view model arrives the second time
+ local sh = context:shader('lensfx')
+ if sh == nil then
+ print('v3_fx: shader missing')
+ return false
+ end
+ self.vm = vm
+ self.inst = self.ui:instance(vm)
+ self.full, self.small = context:canvas(), context:canvas()
+ local out = context:gpuCanvas()
+ self.out = out
+ self.samp = GPUSampler.new({ min = 'linear', mag = 'linear', wrapU = 'clamp-to-edge', wrapV = 'clamp-to-edge' })
+ self.ubo = GPUBuffer.new({ size = 96, usage = 'uniform' })
+ self.pipe = GPUPipeline.new({ vertex = sh, fragment = sh, vertexLayout = {}, colorTargets = { { format = out.format } } })
+ self.ready = true -- the native copy stays fully revealed until a GPU frame exists
+ return true
+end
+
+-- resize can arrive before init has the view model, so sizing is applied lazily from draw
+local function applySize(self: Fx)
+ if self.w < 1 or self.h < 1 then return end
+ self.k = math.min(math.max(self.scale, 0.01), MAXTEX / self.w)
+ local W, H = math.floor(self.w * self.k), math.floor(self.h * self.k)
+ local full, small, out = self.full, self.small, self.out
+ if full == nil or small == nil or out == nil then return end
+ full:resize(W, H)
+ small:resize(math.floor(W / 4), math.floor(H / 4))
+ out:resize(W, H)
+ self.group = nil
+ self.sizedW = W
+end
+
+function resize(self: Fx, size: Vector, scale: number)
+ self.w, self.h, self.scale = size.x, size.y, scale
+ self.sizedW = 0
+end
+
+function advance(self: Fx, seconds: number): boolean
+ self.t += seconds
+ local dt = math.max(seconds, 0)
+ local inst, vm = self.inst, self.vm
+ if vm == nil or not self.shown then
+ if inst then inst:advance(seconds) end
+ return true
+ end
+ intro(self, vm)
+ local power, speed, limit = num(vm, 'powerLevel'), num(vm, 'egoSpeed'), num(vm, 'fusedSpeedLimit')
+ -- power: filtered once here; the native fill, its head and the shader all read this one value
+ local previous = self.pow - self.regen
+ local shown = ease(previous, math.clamp(power / 100, -1, 1), 20, dt)
+ self.pow, self.regen = math.max(shown, 0), math.max(-shown, 0)
+ local display = shown
+ if not self.introDone then
+ local release = smoother((self.introT - 0.88) / 0.60) -- the sweep descends straight into live power
+ display = self.sweep + shown * release
+ end
+ self.display = display
+ setNum(vm, 'displayPowerPct', display * 100)
+ self.over = ease(self.over, (limit > 0) and math.clamp((speed - limit) / 15, 0, 1) or 0, 12, dt)
+ local function fade(cur: number, target: number): number
+ return ease(cur, target, target > cur and 12 or 15, dt)
+ end
+ local light = num(vm, 'trafficLightColor')
+ self.red = fade(self.red, light == 1 and 1 or 0)
+ self.green = fade(self.green, light == 2 and 1 or 0)
+ self.yellow = fade(self.yellow, light == 3 and 1 or 0)
+ local lb, rb = num(vm, 'leftBlinker'), num(vm, 'rightBlinker')
+ self.lOn = ease(self.lOn, lb == 2 and 1 or 0, 60, dt)
+ self.rOn = ease(self.rOn, rb == 2 and 1 or 0, 60, dt)
+ if inst then inst:advance(seconds) end -- after the writes, so this frame shows them
+ return true
+end
+
+-- the instrument keeps its 2280 x 1080 design: scaled to fit the screen and centred (contain)
+local DW, DH = 2280, 1080
+local function fit(self: Fx): (number, number, number)
+ local s = math.min(self.w / DW, self.h / DH)
+ return s, (self.w - DW * s) / 2, (self.h - DH * s) / 2
+end
+
+local function paint(canvas: Canvas, inst: Artboard, k: number, s: number, ox: number, oy: number)
+ local r = canvas:beginFrame({ clearColor = Color.rgba(0, 0, 0, 255) })
+ r:save()
+ local m = Mat2D.identity()
+ m.xx, m.yy = k * s, k * s
+ m.tx, m.ty = k * ox, k * oy
+ r:transform(m)
+ inst:draw(r)
+ r:restore()
+ canvas:endFrame()
+end
+
+function draw(self: Fx, renderer: Renderer)
+ local full, small, out, pipe, ubo, samp, inst, vm =
+ self.full, self.small, self.out, self.pipe, self.ubo, self.samp, self.inst, self.vm
+ if not self.ready or full == nil or small == nil or out == nil or pipe == nil or ubo == nil
+ or samp == nil or inst == nil or vm == nil or self.w < 1 then
+ return
+ end
+ if self.sizedW == 0 then applySize(self) end
+ if self.sizedW == 0 then return end
+ local first = not self.shown
+ if first then
+ -- the first GPU frame: start the ignition from dark (unless the car is already moving or the
+ -- GPU took too long, then adopt the finished live state with no replay)
+ if self.t < 0.10 and math.abs(num(vm, 'egoSpeed')) <= 0.5 then
+ for _, n in INTRO do setNum(vm, n, 0) end
+ else
+ self.introDone = true
+ local live = math.clamp(num(vm, 'powerLevel') / 100, -1, 1)
+ self.pow, self.regen = math.max(live, 0), math.max(-live, 0)
+ self.display = live
+ setNum(vm, 'displayPowerPct', live * 100)
+ for _, n in INTRO do setNum(vm, n, 1) end
+ end
+ setNum(vm, 'introSweep', 0)
+ setNum(vm, 'introTell', 0)
+ inst:advance(0)
+ self.t = 0
+ end
+ local s, ox, oy = fit(self)
+ paint(full, inst, self.k, s, ox, oy)
+ paint(small, inst, self.k / 4, s, ox, oy)
+ local g: GPUBindGroup
+ local cached = self.group
+ if cached ~= nil then
+ g = cached
+ else
+ g = GPUBindGroup.new({ layout = pipe:getBindGroupLayout(0),
+ textures = { { slot = 0, view = full.image:view() }, { slot = 1, view = small.image:view() } },
+ samplers = { { slot = 2, sampler = samp } }, ubos = { { slot = 3, buffer = ubo } } })
+ self.group = g
+ end
+ local q = num(vm, 'renderQuality')
+ if q < 1 then q = 3 end
+ local fillv = math.max(self.display, 0)
+ local v = self.buf
+ local function put(i: number, x: number) buffer.writef32(v, i * 4, x) end
+ put(0, self.w); put(1, self.h); put(2, self.t); put(3, q)
+ put(4, fillv); put(5, math.max(-self.display, 0)); put(6, smoother((fillv - 0.80) / 0.12)); put(7, self.over)
+ put(8, self.red); put(9, self.green); put(10, self.yellow); put(11, self.introDone and 99 or self.introT)
+ put(12, self.lOn); put(13, self.rOn); put(14, self.sweep); put(15, self.peak)
+ put(16, K); put(17, OY); put(18, GLOW_X); put(19, GLOW_Y)
+ put(20, ox); put(21, oy); put(22, s); put(23, 0)
+ ubo:write(v)
+ local pass = out:beginRenderPass({ color = { { loadOp = 'clear', storeOp = 'store', clearColor = { 0, 0, 0, 1 } } } })
+ pass:setPipeline(pipe); pass:setBindGroup(0, g); pass:draw(3); pass:finish()
+ if first then
+ self.shown = true -- the prepared frame is out: now hide the native copy
+ local fx = vm:getBoolean('fxActive')
+ if fx then fx.value = true end
+ end
+ renderer:save()
+ local m = Mat2D.identity()
+ m.xx, m.yy = 1 / self.k, 1 / self.k
+ renderer:transform(m)
+ renderer:drawImage(out.image, self.img, 'srcOver', 1)
+ renderer:restore()
+end
+
+return function(context: Context): Layout
+ return {
+ ui = late(), inst = nil, vm = nil, full = nil, small = nil, out = nil,
+ pipe = nil, group = nil, ubo = nil, buf = buffer.create(96),
+ samp = nil, img = ImageSampler('clamp', 'clamp', 'bilinear'),
+ w = 0, h = 0, k = 1, t = 0, ready = false, scale = 1, sizedW = 0,
+ pow = 0, regen = 0, red = 0, green = 0, yellow = 0, lOn = 0, rOn = 0,
+ over = 0, introDone = false, sweep = 0, scaleSeen = 0, peak = 0, introT = -1,
+ display = 0, shown = false, restored = false,
+ init = init, resize = resize, advance = advance, draw = draw,
+ }
+end
diff --git a/dash-apps/contest/prag-designs-monte-carlo/src/viewmodel.part b/dash-apps/contest/prag-designs-monte-carlo/src/viewmodel.part
new file mode 100644
index 00000000..ec1590c4
--- /dev/null
+++ b/dash-apps/contest/prag-designs-monte-carlo/src/viewmodel.part
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+