diff --git a/dash-apps/contest/prag-designs-parabellum/README.md b/dash-apps/contest/prag-designs-parabellum/README.md new file mode 100644 index 0000000..d2eaee5 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/README.md @@ -0,0 +1,47 @@ +# Parabellum + +**Category:** Rive +**File:** [`parabellum.riv`](parabellum.riv) (about 680 KB), built from [`src/`](src/) + +![Parabellum](preview.jpg) + +Parabellum is an instrument cluster poured from liquid: pink neon through glass. The name is Latin for "prepare for war". Two open ribbons curve around the car like glass tubes. Speed is on the left, 0 to 200 km/h. Power is on the right, in kW. The liquid in each tube rises with the reading and tilts and sloshes when the reading changes fast. Bubbles rise through it, and a bright line rides the surface. Drive over the limit and the speed liquid boils and runs hot red while the limit sign burns. Brake into regen and the power liquid turns lavender and flows the other way, with REGEN under the kW. The two big numbers stay still at a steady speed. They pulse on every change, glow pink while you accelerate and cool to lavender when you brake or regenerate. Between the ribbons your car is seen from above with lane marks passing at your speed, blue while driver assist is on. The limit sign, a traffic light, the neighbour in your blind spot, the battery and the gear sit on the same centre line. + +## What it shows + +| Data | How | +|---|---| +| `egoSpeed` | the big number on the left and the liquid level in the left ribbon. It also sets how fast the lane marks pass (backwards in reverse) and how much the liquid sloshes | +| `fusedSpeedLimit` | the limit sign above the car, hidden while unknown. Over the limit the speed liquid boils and runs red and the sign burns red | +| `accSetSpeed`, `adasOn` | CRUISE and the set speed under km/h (greyed to SET while you override it); ASSIST ON with a steering wheel icon, and the lane marks turn blue | +| `packVoltage`, `packCurrent` | kW (volts times amps) as the big number on the right and the power ribbon, 0 to 100 kW. Negative power is regen: the liquid turns lavender and REGEN appears | +| `nominalEnergyRemaining`, `fullPackEnergy`, `energyBuffer` | battery percentage and the slanted battery bar, which turns red when the charge runs low (about 20 %) | +| `gear` | P R N D chips with the current one lit. In R the lane marks run backwards | +| `leftBlinker`, `rightBlinker` | a green arrow beside the car, lit in step with the lamp, and a green light running along that ribbon's rim | +| `leftBlindSpot`, `rightBlindSpot` | a car in the next lane on that side, amber for a vehicle and red with a warning mark when urgent | +| `trafficLightColor` | three lamps under the clock, lit red, yellow or green | +| `currentTime` | the clock at the top | +| `renderQuality` | the glass 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 and power in kW. There is no mph switch. + +When DashPilot starts with the car parked, both ribbons pour full and then drain to the real reading in about two seconds. Moving cancels it. + +## How it is built + +The ribbons, numbers, car, signs and every data bind on `DashViewModel` are native Rive. A scripted layout (`src/ribbon_fx.luau`) draws a private copy of that instrument into a texture each frame and finishes it in a WGSL pass (`src/ribbonglass.wgsl`): the liquid, its bubbles, the glow and the lane marks. While the script 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: flat ribbons, 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. + +The two big numbers get their glow from the same script. It writes six numbers of its own into the view model (`spdHot`, `spdCool`, `spdPunch`, `kwHot`, `kwCool`, `kwPunch`) that fade a tinted copy of each number in and scale it a little. Without scripts they stay at their calm defaults, so the number is plain white. + +- `src/ribbon.py` writes `src/scene.rml` from `viewmodel.part`, `converters.part`, `mine.part` and `ribbon.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 a few properties of my own that only the script writes (`fxActive`, `laneScroll`, `intro*` and the six above). +- Rebuild: `cd src && python3 ribbon.py && rive . --publish`. This writes `../parabellum.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 liquid layer 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 + +- **Chakra Petch** font (`src/fonts/chakrapetch-500.ttf`, `chakrapetch-600.ttf`) by The Chakra Petch Project Authors, [SIL Open Font License 1.1](src/fonts/OFL-ChakraPetch.txt). It sets every number and label. +- **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. +- **Car** (`src/car.png`, the top-down car in the middle, also tinted for the blind-spot car): generated for this entry with OpenAI's image model. It contains no photograph and no logo. +- Everything else (the ribbons, liquid, bubbles, numbers, scales, icons, lane marks, the shader and the start-up) is my own work in this folder. No other images, icons or sounds. diff --git a/dash-apps/contest/prag-designs-parabellum/parabellum.riv b/dash-apps/contest/prag-designs-parabellum/parabellum.riv new file mode 100644 index 0000000..197e3b7 Binary files /dev/null and b/dash-apps/contest/prag-designs-parabellum/parabellum.riv differ diff --git a/dash-apps/contest/prag-designs-parabellum/preview.jpg b/dash-apps/contest/prag-designs-parabellum/preview.jpg new file mode 100644 index 0000000..0287385 Binary files /dev/null and b/dash-apps/contest/prag-designs-parabellum/preview.jpg differ diff --git a/dash-apps/contest/prag-designs-parabellum/src/.gitignore b/dash-apps/contest/prag-designs-parabellum/src/.gitignore new file mode 100644 index 0000000..3d43339 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/.gitignore @@ -0,0 +1,2 @@ +build/ +__pycache__/ diff --git a/dash-apps/contest/prag-designs-parabellum/src/car.png b/dash-apps/contest/prag-designs-parabellum/src/car.png new file mode 100644 index 0000000..fca0e98 Binary files /dev/null and b/dash-apps/contest/prag-designs-parabellum/src/car.png differ diff --git a/dash-apps/contest/prag-designs-parabellum/src/converters.part b/dash-apps/contest/prag-designs-parabellum/src/converters.part new file mode 100644 index 0000000..eca31e2 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/converters.part @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dash-apps/contest/prag-designs-parabellum/src/fonts/OFL-ChakraPetch.txt b/dash-apps/contest/prag-designs-parabellum/src/fonts/OFL-ChakraPetch.txt new file mode 100644 index 0000000..9cee5a5 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/fonts/OFL-ChakraPetch.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) + +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-parabellum/src/fonts/chakrapetch-500.ttf b/dash-apps/contest/prag-designs-parabellum/src/fonts/chakrapetch-500.ttf new file mode 100644 index 0000000..2e2d8da Binary files /dev/null and b/dash-apps/contest/prag-designs-parabellum/src/fonts/chakrapetch-500.ttf differ diff --git a/dash-apps/contest/prag-designs-parabellum/src/fonts/chakrapetch-600.ttf b/dash-apps/contest/prag-designs-parabellum/src/fonts/chakrapetch-600.ttf new file mode 100644 index 0000000..802d598 Binary files /dev/null and b/dash-apps/contest/prag-designs-parabellum/src/fonts/chakrapetch-600.ttf differ diff --git a/dash-apps/contest/prag-designs-parabellum/src/fonts/inter-400.ttf b/dash-apps/contest/prag-designs-parabellum/src/fonts/inter-400.ttf new file mode 100644 index 0000000..3dda73d Binary files /dev/null and b/dash-apps/contest/prag-designs-parabellum/src/fonts/inter-400.ttf differ diff --git a/dash-apps/contest/prag-designs-parabellum/src/fonts/inter-600.ttf b/dash-apps/contest/prag-designs-parabellum/src/fonts/inter-600.ttf new file mode 100644 index 0000000..6dc4fdf Binary files /dev/null and b/dash-apps/contest/prag-designs-parabellum/src/fonts/inter-600.ttf differ diff --git a/dash-apps/contest/prag-designs-parabellum/src/gen.py b/dash-apps/contest/prag-designs-parabellum/src/gen.py new file mode 100644 index 0000000..8cf86a9 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/gen.py @@ -0,0 +1,558 @@ +"""Luce Night (fx1c) in Rive, 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", + "spdHot": "1:90", "spdCool": "1:91", "spdPunch": "1:92", "kwHot": "1:93", "kwCool": "1:94", "kwPunch": "1:95", +} +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}{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-parabellum/src/mine.part b/dash-apps/contest/prag-designs-parabellum/src/mine.part new file mode 100644 index 0000000..c575594 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/mine.part @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dash-apps/contest/prag-designs-parabellum/src/ribbon.part b/dash-apps/contest/prag-designs-parabellum/src/ribbon.part new file mode 100644 index 0000000..ba16809 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/ribbon.part @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dash-apps/contest/prag-designs-parabellum/src/ribbon.py b/dash-apps/contest/prag-designs-parabellum/src/ribbon.py new file mode 100644 index 0000000..156bcd2 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/ribbon.py @@ -0,0 +1,336 @@ +"""DashPilot v2 "Ribbon": two hatched ribbons frame a top-down car. Magenta palette, white sedan. + +Built from the approved Codex frame (codex/c3.png palette, codex/c1.png car). Coordinates are +measured on that 1536 x 1024 frame and mapped onto the 2280 x 1080 phone artboard by P(): +scaled 1.05, the two gauges pushed DX further out so the wide screen breathes. +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, ellipse, rect, poly, + text, SM, fx_layer, X, Y, OPACITY, TEXT, TRIM_END, SCALE_X, SCALE_Y) + +W, H = 2280, 1080 +S, DX = 1.05, 170 +EXO, EXO_B = "2:902", "2:903" +WHITE, SOFT, DIM = "FFF4F1F7", "FFD8D4DC", "FF55525A" +MAGENTA, OUTLINE = "FFE51FD0", "FFEE63E4" +TRACK, HATCH = "FF17181B", "FF0B0C0E" # empty track: same stripes, muted so it recedes behind the liquid +AMBER, RED = "FFFFC21A", "FFFF3B30" +FILL = [("FFD91CFB", 0), ("FFFD4CBD", 0.25), ("FFFE3BCF", 0.6), ("FFFC00FB", 1)] # violet tip, pink body, magenta top +REGEN = [("FF9C8CFF", 0), ("FFC9BEFF", 1)] +# left gauge, measured on the frame: centre, outline radius, ribbon radii, the angles of its two ends +GC, R_LINE, R_OUT, R_IN = (465, 480), 400, 381, 282 +A_BOT, A_TOP = 171, 360 + + +def P(x, y, side=0): + return 1140 + (x - 768) * S + side * DX, 540 + (y - 512) * S + + +def arc(c, r, a0, a1, n=96): + """Points on a circle from a0 to a1 (degrees, clockwise from 12 o'clock).""" + return [(c[0] + r * math.sin(math.radians(a)), c[1] - r * math.cos(math.radians(a))) + for a in (a0 + (a1 - a0) * i / n for i in range(n + 1))] + + +def rim_path(c, r, a_bot, a_top, rho=9.0, tick=21.0, n=14): + """The rim as ONE path: end tick, rounded corner, the arc, rounded corner, end tick. Same shape at all four ends.""" + def at(a, rr): + ar = math.radians(a) + return (c[0] + rr * math.sin(ar), c[1] - rr * math.cos(ar)) + def frame(a): + ar = math.radians(a) + return (math.sin(ar), -math.cos(ar)), (math.cos(ar), math.sin(ar)) # outward, along the rim (growing angle) + dd = math.degrees(rho / r) + pts = [] + nb, tb = frame(a_bot) + cb = at(a_bot, r) + ctr = (cb[0] + tb[0] * rho + nb[0] * rho, cb[1] + tb[1] * rho + nb[1] * rho) + pts.append((cb[0] + nb[0] * tick, cb[1] + nb[1] * tick)) + for i in range(n + 1): + s_ = math.radians(90 - 90 * i / n) + pts.append((ctr[0] + rho * (-nb[0] * math.cos(s_) - tb[0] * math.sin(s_)), ctr[1] + rho * (-nb[1] * math.cos(s_) - tb[1] * math.sin(s_)))) + pts += arc(c, r, a_bot + dd, a_top - dd, 96) + nt, tt_ = frame(a_top) + ct = at(a_top, r) + ut = (-tt_[0], -tt_[1]) # into the rim at the top end + ctr = (ct[0] + ut[0] * rho + nt[0] * rho, ct[1] + ut[1] * rho + nt[1] * rho) + for i in range(n + 1): + s_ = math.radians(90 * i / n) + pts.append((ctr[0] + rho * (-nt[0] * math.cos(s_) - ut[0] * math.sin(s_)), ctr[1] + rho * (-nt[1] * math.cos(s_) - ut[1] * math.sin(s_)))) + pts.append((ct[0] + nt[0] * tick, ct[1] + nt[1] * tick)) + return pts + + +def gauge(name, side, labels, fills, extra): + """One ribbon gauge. side -1 = left as measured, +1 = mirrored to the right.""" + m = (lambda p: p) if side < 0 else (lambda p: (W - p[0], p[1])) + c = P(*GC, -1) + r_out, r_in = R_OUT * S, R_IN * S + r_line = r_out + 4 # the outline is the ribbon's own rim: no gap, one piece + r_mid, band = (r_out + r_in) / 2, r_out - r_in + k = [] + # outline, outside the ribbon with a dark gap, and a notch at every label + line = [m(p) for p in arc(c, r_line, A_BOT, A_TOP)] # the fills keep their own gradient range + ys = [p[1] for p in line] + rim = [m(p) for p in rim_path(c, r_line, A_BOT, A_TOP)] # the rim: one stroke, the end ticks are part of it + rys = [p[1] for p in rim] + k.append(shape(f"{name} Outline", poly(rim, False) + grad_stroke(6, [("FF9B2BF2", 0), (OUTLINE, 0.5), ("FFFF3FD8", 1)], 0, max(rys), 0, min(rys), cap="round"))) + # the ribbon body: outer arc to a slanted top cut, inner arc back to a slanted bottom cut + body = [m(p) for p in arc(c, r_out, A_BOT, A_TOP) + arc(c, r_in, A_TOP - 8, A_BOT + 5)] + clip_id = nid() + k.append(f'{poly(body)}{fill(TRACK)}') + clip = f'' + # diagonal hatching in the unlit part + bx = [p[0] for p in body] + by = [p[1] for p in body] + x0, x1, y0, y1 = min(bx) - 60, max(bx) + 60, min(by) - 60, max(by) + 60 + hatch = [] + tilt = math.radians(-32 * side) # stripes lean the same way the ribbon runs on each side + for i, hx in enumerate(range(int(x0 - (y1 - y0) * 0.7), int(x1 + (y1 - y0) * 0.7), 17)): + hatch.append(f'') + k.append(f'{"".join(hatch)}{fill(HATCH)}{clip}') + # live fills along the ribbon's centre line, clipped to the ribbon so the ends stay cut + mid = poly([m(p) for p in arc(c, r_mid, A_BOT - 2, A_TOP + 1)], False) + for i, (stops, b) in enumerate(fills): + k.append(shape(f"{name} Glow {i}", mid + grad_stroke(band * 0.9, [(s[0][:2].replace("FF", "66") + s[0][2:], s[1]) for s in stops], + 0, max(ys), 0, min(ys), trim(0, 0, b) + glow(26))).replace("", clip + "")) + k.append(f'{mid}' + f'{grad_stroke(band + 8, stops, 0, max(ys), 0, min(ys), trim(0, 0, b))}{clip}') + # notches and numbers + for i, lab in enumerate(labels): + a = A_BOT + (A_TOP - A_BOT) * i / (len(labels) - 1) + nx, ny = arc(c, r_line + 13, a, a, 1)[0] # ticks stand just outside the rim + tx, ty = arc(c, r_line + 50, a, a, 1)[0] + nx, ny = m((nx, ny)) + tx, ty = m((tx, ty)) + if 0 < i < len(labels) - 1: # the two end ticks are part of the rim path + k.append(shape(f"{name} Notch {i}", rect(7, 22) + fill(OUTLINE), nx, ny, math.radians(a if side < 0 else 360 - a))) + k.append(text(f"{name} Num {i}", [(lab, "")], tx, ty, 30, WHITE, EXO, w=90)) + return node(name, k + extra) + + +def arrow(name, x, y, d, b): + s = 1 if d > 0 else -1 + pts = [(-s * 30, -9), (s * 2, -9), (s * 2, -22), (s * 30, 0), (s * 2, 22), (s * 2, 9), (-s * 30, 9)] + return [shape(f"{name} Dim", poly(pts) + fill("FF302C36"), x, y), + node(name, [shape(f"{name} Glow", poly(pts) + fill("9935D879", glow(14))), shape(f"{name} Lit", poly(pts) + fill("FF35D879"))], x, y, 0, b)] + + +def neighbour(name, x, y, prop, b_amber, b_red, side): + """A car in the next lane while that side's blind spot reports one: the same sedan, smaller than ours, + softly tinted amber (red when urgent), sliding up from behind as it arrives.""" + def car(color, nm): + img = (f'') + tint = f'{rect(100, 180, 0, 0, 20)}{fill(color)}' + return [shape(f"{nm} Halo", rect(56, 120, 0, 0, 26) + fill("40" + color[2:], glow(22))), img, tint] + warn = node(f"{name} Warn", [shape(f"{name} Warn Glow", poly([(0, -19), (20, 15), (-20, 15)]) + fill("66FF3B30", glow(10))), + shape(f"{name} Warn Sign", poly([(0, -19), (20, 15), (-20, 15)]) + fill(RED)), + text(f"{name} Warn Mark", [("!", "")], 0, 3, 30, "FFFFF4F2", EXO_B, w=30)], side * 70, -62) + return [node(f"{name} Slide", [node(f"{name} Amber", car("FFFFB547", f"{name} A"), 0, 0, 0, b_amber), + node(f"{name} Red", car("FFFF5B4D", f"{name} R") + [warn], 0, 0, 0, b_red)], + 0, 0, 0, bind(prop, Y, "8:104")).replace('') + k += [node("Blind L Lane", neighbour("Blind L", 0, 0, "lbs", bind("lbs", OPACITY, "8:105"), bind("lbs", OPACITY, "8:106"), -1), W / 2 - 245, 600)] + k += [node("Blind R Lane", neighbour("Blind R", 0, 0, "rbs", bind("rbs", OPACITY, "8:105"), bind("rbs", OPACITY, "8:107"), 1), W / 2 + 245, 600)] + # driver assist: a blue steering wheel and ASSIST ON under the car while it is engaged + ay = 728 + # a real steering wheel: rim, hub and three spokes (the old ring and bar read as a minus button) + wx = -118 + blue = "FF64CFFF" + k.append(node("Assist", [shape("Assist Glow", ellipse(40) + fill("3364CFFF", glow(10)), wx, 0), + shape("Assist Rim", ellipse(34) + stroke(blue, 3.5), wx, 0), + shape("Assist Hub", ellipse(9) + fill(blue), wx, 2), + shape("Assist Spoke L", rect(11, 3.5, 0, 0, 1.5) + fill(blue), wx - 10, 2), + shape("Assist Spoke R", rect(11, 3.5, 0, 0, 1.5) + fill(blue), wx + 10, 2), + shape("Assist Spoke D", rect(3.5, 11, 0, 0, 1.5) + fill(blue), wx, 10), + text("Assist Text", [("ASSIST ON", "")], 16, 0, 34, blue, EXO, w=260, spacing=3)], + W / 2, ay, 0, bind("adas", OPACITY, "2:41"))) + # battery: a small ribbon of its own, cut at the same slant as the big ribbons' ends. Same rim, same + # liquid: a bright surface line at the level (slanted like the cuts), a gloss along the top, quarter ticks + # on the rim, and the liquid turns red when the charge runs low + bx, by, bw, bh, sk = W / 2 + 18, P(768, 762)[1], 236, 26, 12 # group centred on the car + cell = [(bx - bw / 2 + sk, by - bh / 2), (bx + bw / 2 + sk, by - bh / 2), (bx + bw / 2 - sk, by + bh / 2), (bx - bw / 2 - sk, by + bh / 2)] + bid = nid() + k.append(f'{poly(cell)}{fill(TRACK)}') + bclip = f'' + stripes = "".join(f'' + for i, hx in enumerate(range(int(bx - bw / 2 - 40), int(bx + bw / 2 + 40), 11))) + k.append(f'{stripes}{fill(HATCH)}{bclip}') + full = bw + 2 * sk + bx0 = bx - bw / 2 - sk # the cell's bottom-left corner: origin of the level nodes + liquid = (f'{rect(full, bh, full / 2, 0)}' + f'{grad_fill("LinearGradient", [(FILL[0][0], 0), (FILL[1][0], 0.45), (FILL[3][0], 1)], sx=0, sy=0, ex=full, ey=0)}{bclip}') + low = (f'{rect(full, bh, full / 2, 0)}' + f'{grad_fill("LinearGradient", [("FFFF453A", 0), ("FFFF7A5C", 1)], sx=0, sy=0, ex=full, ey=0)}{bclip}') + # the liquid is always full; the empty part is a slanted cover that slides over it, so the fill ends on the same cut as the shape + k.append(node("Battery Level", [liquid, node("Battery Low Tint", [low], 0, 0, 0, bind("energy", OPACITY, "9:20"))], bx0, by)) + k.append(shape("Battery Gloss", poly([(bx - bw / 2 + sk - 2, by - bh / 2 + 4), (bx + bw / 2 + sk - 4, by - bh / 2 + 4)], False) + + stroke("59FFFFFF", 2)).replace("", bclip + "")) + # the empty part: a slanted cover that slides over the full liquid. Only filled shapes here: an open stroked line or a + # clip whose source moves turns the Android GPU layer off or black (found on the phone 26 Oct) + cover = [(sk, -bh / 2), (full + 40, -bh / 2), (full + 40, bh / 2), (-sk, bh / 2)] + def cut_band(off, w): # a bar parallel to the cut, off px to its right + return [(sk + off, -bh / 2), (sk + off + w, -bh / 2), (-sk + off + w, bh / 2), (-sk + off, bh / 2)] + lean = bh * math.tan(0.56) # the hatch slant of the bar + stripes = "".join(poly([(b + lean, -bh / 2), (b + lean + 4, -bh / 2), (b + 4, bh / 2), (b, bh / 2)]) + for b in range(0, int(full + 40), 11)) # every stripe starts right of the cut + empty = shape("Battery Empty", poly(cover) + fill(TRACK)).replace("", bclip + "") + ehatch = shape("Battery Empty Hatch", stripes + fill(HATCH)).replace("", bclip + "") + sline = shape("Battery Surface Glow", poly(cut_band(-4, 8)) + fill("66FF91EA")).replace("", bclip + "") + sedge = shape("Battery Surface", poly(cut_band(-1.5, 3)) + fill("F2FFF0FC")).replace("", bclip + "") + k.append(node("Battery Empty Offset", [node("Battery Empty Pos", [empty, ehatch, sline, sedge], 0, 0, 0, bind("energy", X, "9:70"))], bx0, by)) + # the rim: the ribbons' magenta, so the bar reads as part of the same set; ticks at 25, 50 and 75 % + k.append(shape("Battery Rim", poly(cell) + grad_stroke(2, [("FF9B2BF2", 0), (OUTLINE, 0.5), ("FFFF3FD8", 1)], bx - bw / 2, 0, bx + bw / 2, 0))) + k.append(text("Charge", [("69", bind("energy", TEXT, "2:17")), ("%", "", 26, SOFT, EXO_B)], bx + bw / 2 + 74, by, 40, WHITE, EXO_B, w=150)) + k.append(text("Battery Label", [("BATTERY", "")], bx - bw / 2 - 100, by, 28, "FFAAA3B5", EXO_B, w=170, spacing=2)) + # gear: P R N D as round chips; the engaged one fills with the ribbon's pink and glows + gy = P(768, 868)[1] + for i, (g_, conv) in enumerate((("P", "2:11"), ("R", "2:12"), ("N", "2:13"), ("D", "2:14"))): + gx = W / 2 + (i - 1.5) * 74 + k.append(shape(f"Chip {g_}", ellipse(58) + fill("FF111015") + stroke("FF3A3740", 2), gx, gy)) + k.append(text(f"Gear {g_} Dim", [(g_, "")], gx, gy, 34, "FF8F8A99", EXO_B, w=60)) # Chakra Petch caps sit centred as set + k.append(node(f"Gear {g_} On", [shape(f"Chip {g_} Glow", ellipse(66) + fill("88E51FD0", glow(22))), + shape(f"Chip {g_} Lit", ellipse(58) + grad_fill("LinearGradient", [(FILL[3][0], 0), (FILL[1][0], 1)], sx=0, sy=-29, ex=0, ey=29)), + shape(f"Chip {g_} Rim", ellipse(58) + stroke("66FFFFFF", 1.5)), + text(f"Gear {g_}", [(g_, "")], 0, 0, 36, WHITE, EXO_B, w=60)], + gx, gy, 0, bind("gear", OPACITY, conv))) + return node("Centre", k) + + +def punch_text(name, prop, conv, size, color, w, x, y, hot, cool, scale): + """A big number with punch and glow: the white number, a hot-pink copy and a cool lavender copy that fade in with + effort (the GPU script writes the three numbers), all scaled together on every change. Still and white at rest.""" + def t(nm, col, glow_px=0, opac=None): + return text(nm, [("86", bind(prop, TEXT, conv))], 0, 0, size, col, EXO_B, w=w, glow_px=glow_px, + binds=(bind(opac, OPACITY) if opac else "")) + return node(name + " Punch", [t(name, color), t(name + " Hot", "FFFF86E6", 20, hot), t(name + " Cool", "FFB9A8FF", 20, cool)], + x, y, 0, bind(scale, SCALE_X) + bind(scale, SCALE_Y)) + + +def readouts(side): + # speed leads: 200 px; power answers at 112 px, signed, so regen reads as a negative kW + if side < 0: + x, y = P(405, 462, -1) + ux, uy = P(408, 557, -1) + k = [punch_text("Speed", "speed", "2:1", 200, "FFF6F3F8", 520, x, y, "spdHot", "spdCool", "spdPunch"), + text("Speed Unit", [("km/h", "")], ux, uy + 16, 42, "FFC9C5D0", EXO, w=200), + node("Set", [text("Set Speed", [("CRUISE ", "", 28, "FFBCA5F4", EXO_B), ("95", bind("set", TEXT, "2:1"))], ux, uy + 76, 34, "FFBCA5F4", EXO_B, w=300, + binds=bind("speed", OPACITY, "9:63")), + text("Set Stored", [("SET ", "", 28, "FF8E8A96", EXO_B), ("95", bind("set", TEXT, "2:1"))], ux, uy + 76, 34, "FF8E8A96", EXO_B, w=300, + binds=bind("speed", OPACITY, "9:62"))], + 0, 0, 0, bind("adas", OPACITY, "2:41"))] + else: + x, y = P(1150, 462, 1) + ux, uy = P(1150, 557, 1) + k = [punch_text("Kilowatts", "amps", "9:13", 112, "FFE2DDE8", 360, x, y + 10, "kwHot", "kwCool", "kwPunch"), + text("Kw Unit", [("kW", "")], ux, uy - 20, 32, "FFB9B3C2", EXO, w=200), + text("Regen Lit", [("REGEN", "")], ux, uy + 26, 30, "FFC7B7FF", EXO_B, w=200, spacing=2, binds=bind("amps", OPACITY, "9:16"))] + return k + + +def instrument(gpu=False): + left = gauge("Speed", -1, [str(v) for v in range(0, 201, 40)], + [] if gpu else [(FILL, bind("speed", TRIM_END, "9:2"))], readouts(-1)) + cx_, cy_ = P(150, 868, -1) + captions = [text("Power Caption", [("kW", "")], W - cx_, cy_, 24, "FFAAA3B5", EXO_B, w=160, spacing=2)] + right = gauge("Power", 1, ["0", "25", "50", "75", "100"], + [] if gpu else [(REGEN, bind("amps", TRIM_END, "9:15")), (FILL, bind("amps", TRIM_END, "9:14"))], readouts(1)) + return node("Instrument", [left, right, centre()] + captions) + + +def scene(): + # Dash: what DashPilot loads. The GPU layer on top, the native instrument under it (hidden once the layer runs). + # 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")} + ''' + # Instrument: the same instrument, drawn by the GPU layer into a texture with the live view model. + inst = f''' + + + {instrument(gpu=True)} + {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", "ribbon.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-parabellum/src/ribbon_fx.luau b/dash-apps/contest/prag-designs-parabellum/src/ribbon_fx.luau new file mode 100644 index 0000000..5c1246b --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/ribbon_fx.luau @@ -0,0 +1,283 @@ +-- Ribbon GPU layer. Renders the native Instrument artboard (bound to DashPilot's live view model) +-- into a texture every frame and finishes it in ribbonglass.wgsl: neon bloom, glossy ribbons with +-- energy flowing in them, a white-hot head on each fill, streetlights sliding over the car, lane +-- marks passing at the car's speed, blind-spot radar, and light from the traffic light and blinkers. +-- Everything moves only because the car moves: parked, the screen is still. +-- 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, + -- smoothed state + spd: number, trail: number, pow: number, regen: number, over: number, + red: number, green: number, yellow: number, lbs: number, rbs: number, + lPrev: number, rPrev: number, lT: number, rT: number, lOn: number, rOn: number, + flowL: number, flowR: number, road: number, glass: number, assist: number, + -- liquid: each ribbon's surface tilts with how fast its level moves, swings back and settles + prevL: number, prevR: number, velL: number, velR: number, + tiltL: number, tiltR: number, tvL: number, tvR: number, ripL: number, ripR: number, + -- shown levels (the start-up sweep on top of the data) and the boil when over the limit + showL: number, showR: number, boil: number, bootOff: boolean, + -- punch and glow on the big numbers: smoothed rate of change, pulse, last values + rS: number, rP: number, pS: number, pP: number, lastSp: number, lastKw: number, lastSpI: number, lastKwI: number, pgInit: boolean, +} + +local MAXW = 2400 +-- layout, in artboard points (ribbon.py lays the instrument out on 2280 x 1080) +local GX, GY, R_IN, R_OUT, R_LINE = 651.85, 506.4, 296.1, 400.05, 404.05 -- the outline sits on the ribbon rim -- left gauge; the right one mirrors +local A_BOT, A_SPAN = math.rad(171), math.rad(189) +local CAR_X, CAR_Y, CAR_TOP, CAR_BOT, CAR_HW = 1140, 496, 344, 648, 70 -- car drawn at 0.54 +local FAN_X, FAN_Y = 1072, 620 -- left rear fan; the right mirrors + +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 ease(cur: number, target: number, rate: number, dt: number): number + return cur + (target - cur) * (1 - math.exp(-rate * dt)) +end + +function init(self: Fx, context: Context): boolean + 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('ribbonglass') + if sh == nil then + print('ribbon_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 = 192, usage = 'uniform' }) + self.pipe = GPUPipeline.new({ vertex = sh, fragment = sh, vertexLayout = {}, colorTargets = { { format = out.format } } }) + local fx = vm:getBoolean('fxActive') + if fx then fx.value = true end -- hides the native copy underneath + self.ready = true + 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(self.scale, MAXW / 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.min(seconds, 1 / 20) + local inst, vm = self.inst, self.vm + if inst then inst:advance(seconds) end + if vm == nil then return true end + local speed, limit = num(vm, 'egoSpeed'), num(vm, 'fusedSpeedLimit') + local gear = num(vm, 'gear') + -- the fills, as fractions of their ribbons; the trail is a slower copy that lights acceleration + self.spd = ease(self.spd, math.clamp(speed / 200, 0, 1), 9, dt) + self.trail = ease(self.trail, self.spd, 2.5, dt) + -- the power ribbon reads the same kW as its number: pack volts x amps, 0-100 kW full scale + local kw = num(vm, 'packVoltage') * num(vm, 'packCurrent') / 1000 + self.pow = ease(self.pow, math.clamp(kw / 100, 0, 1), 6, dt) + -- punch and glow on the two big numbers: they warm up while accelerating, cool while braking or in regen, + -- and pulse a little whenever the shown value changes. The view-model numbers fade tinted copies in (ribbon.py). + if not self.pgInit then + self.pgInit = true + self.lastSp, self.lastKw = speed, kw + self.lastSpI, self.lastKwI = math.floor(speed + 0.5), math.floor(kw + 0.5) + end + if dt > 0 then + self.rS = ease(self.rS, (speed - self.lastSp) / dt, 6, dt) + self.rP = ease(self.rP, (kw - self.lastKw) / dt, 6, dt) + end + self.lastSp, self.lastKw = speed, kw + local si, ki = math.floor(speed + 0.5), math.floor(kw + 0.5) + if si ~= self.lastSpI then self.pS = 1 end + if ki ~= self.lastKwI then self.pP = 1 end + self.lastSpI, self.lastKwI = si, ki + self.pS = ease(self.pS, 0, 9, dt) + self.pP = ease(self.pP, 0, 9, dt) + local function put(name: string, v: number) + local pr = vm:getNumber(name) + if pr then pr.value = v end + end + put('spdHot', math.clamp(self.rS / 26, 0, 1)) + put('spdCool', math.clamp(-self.rS / 30, 0, 1)) + put('spdPunch', 1 + 0.025 * self.pS) + put('kwHot', kw > 0 and math.clamp(self.rP / 90, 0, 1) or 0) + put('kwCool', math.max(math.clamp(-kw / 12, 0, 1), math.clamp(-self.rP / 90, 0, 1))) + put('kwPunch', 1 + 0.02 * self.pP) + self.regen = ease(self.regen, math.clamp(-kw / 100, 0, 1), 6, dt) + self.over = ease(self.over, (limit > 0) and math.clamp((speed - limit) / 15, 0, 1) or 0, 4, dt) + -- lights come in over ~250 ms and leave over ~200 ms + 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) + self.lbs = fade(self.lbs, num(vm, 'leftBlindSpot')) + self.rbs = fade(self.rbs, num(vm, 'rightBlindSpot')) + -- blinkers: remember when the real lamp lights, so the light runs in sync with it + local lb, rb = num(vm, 'leftBlinker'), num(vm, 'rightBlinker') + if lb == 2 and self.lPrev ~= 2 then self.lT = self.t end + if rb == 2 and self.rPrev ~= 2 then self.rT = self.t end + self.lPrev, self.rPrev = lb, rb + self.lOn = ease(self.lOn, lb == 2 and 1 or 0, lb == 2 and 30 or 6, dt) + self.rOn = ease(self.rOn, rb == 2 and 1 or 0, rb == 2 and 30 or 6, dt) + -- motion phases: energy runs up the ribbons with speed and power (down the power ribbon in regen), + -- the road and the streetlights pass at the car's speed, backwards in R + local dir = gear == 2 and -1 or 1 + -- (no wrap: the bubbles ride this phase, and wrapping it made them jump) + self.flowL = self.flowL + self.spd * 0.9 * dt + self.flowR = self.flowR + (self.pow - self.regen) * 0.8 * dt + self.road = (self.road + speed * 2.6 * dir * dt) % 2160 + self.glass = ease(self.glass, 1, 2.5, dt) + local a = vm:getBoolean('adasOn') + self.assist = ease(self.assist, (a and a.value) and 1 or 0, 6, dt) + -- start-up: both ribbons pour full in 0.9 s, hold, then drain to the real reading by 2 s + local bt = self.t - 0.35 + local boot = 0 + if bt > 0 and bt < 0.9 then + local x = bt / 0.9 + boot = 1 - (1 - x) ^ 3 + elseif bt >= 0.9 and bt < 1.15 then + boot = 1 + elseif bt >= 1.15 and bt < 2.0 then + local x = (bt - 1.15) / 0.85 + boot = 1 - x * x * (3 - 2 * x) + end + if self.t < 0.35 then boot = 0 end + -- only on a parked car: moving at any point cancels it and the ribbons jump to the live levels + if speed > 1 then self.bootOff = true end + if self.bootOff then boot = 0 end + self.showL = math.max(self.spd, boot) + self.showR = math.max(math.max(self.pow, self.regen), boot) + -- over the limit the speed liquid boils: eases in over ~0.4 s, settles over ~0.8 s + self.boil = ease(self.boil, self.over, self.over > self.boil and 6 or 3, dt) + -- liquid slosh: a damped spring pulls each surface toward a tilt set by how fast the level moves + local levR = self.showR + if dt > 0 then + self.velL = ease(self.velL, (self.showL - self.prevL) / dt, 8, dt) + self.velR = ease(self.velR, (levR - self.prevR) / dt, 8, dt) + end + self.prevL, self.prevR = self.showL, levR + local function slosh(tilt: number, tv: number, vel: number): (number, number) + local target = math.clamp(vel * 11, -1.2, 1.2) + tv += (-(tilt - target) * 55 - tv * 3.2) * dt + return tilt + tv * dt, tv + end + self.tiltL, self.tvL = slosh(self.tiltL, self.tvL, self.velL) + self.tiltR, self.tvR = slosh(self.tiltR, self.tvR, self.velR) + -- ripples: the road shakes the surface a little while moving, level changes stir it more + local road = math.clamp(speed / 120, 0, 1) * 0.35 + self.ripL = ease(self.ripL, road + math.min(math.abs(self.velL) * 9, 1) + self.boil * 1.4, 3, dt) + self.ripR = ease(self.ripR, road + math.min(math.abs(self.velR) * 9, 1), 3, dt) + 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 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 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) + local bootR = math.max(self.showR - math.max(self.pow, self.regen), 0) + put(4, self.showL); put(5, math.min(self.trail, self.showL)); put(6, self.pow + bootR); put(7, self.regen) + put(8, self.red); put(9, self.green); put(10, self.yellow); put(11, self.over) + put(12, self.lOn); put(13, self.rOn); put(14, self.t - self.lT); put(15, self.t - self.rT) + put(16, GX); put(17, GY); put(18, R_IN); put(19, R_OUT) + put(20, R_LINE); put(21, A_BOT); put(22, A_SPAN); put(23, num(vm, 'egoSpeed')) + put(24, CAR_X); put(25, CAR_Y); put(26, CAR_TOP); put(27, CAR_BOT) + put(28, CAR_HW); put(29, self.lbs); put(30, self.rbs); put(31, self.glass) + put(32, self.flowL); put(33, self.flowR); put(34, self.road); put(35, self.assist) + put(36, FAN_X); put(37, FAN_Y); put(38, self.boil); put(39, 0) + put(40, self.tiltL); put(41, self.tiltR); put(42, self.ripL); put(43, self.ripR) + put(44, ox); put(45, oy); put(46, s); put(47, 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() + 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(192), + samp = nil, img = ImageSampler('clamp', 'clamp', 'bilinear'), + w = 0, h = 0, k = 1, t = 0, ready = false, scale = 1, sizedW = 0, + spd = 0, trail = 0, pow = 0, regen = 0, over = 0, + red = 0, green = 0, yellow = 0, lbs = 0, rbs = 0, + lPrev = 0, rPrev = 0, lT = -10, rT = -10, lOn = 0, rOn = 0, + flowL = 0, flowR = 0, road = 0, glass = 0, assist = 0, + showL = 0, showR = 0, boil = 0, bootOff = false, + rS = 0, rP = 0, pS = 0, pP = 0, lastSp = 0, lastKw = 0, lastSpI = 0, lastKwI = 0, pgInit = false, + prevL = 0, prevR = 0, velL = 0, velR = 0, tiltL = 0, tiltR = 0, tvL = 0, tvR = 0, ripL = 0, ripR = 0, + init = init, resize = resize, advance = advance, draw = draw, + } +end diff --git a/dash-apps/contest/prag-designs-parabellum/src/ribbonglass.wgsl b/dash-apps/contest/prag-designs-parabellum/src/ribbonglass.wgsl new file mode 100644 index 0000000..e9d9647 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/ribbonglass.wgsl @@ -0,0 +1,271 @@ +// Ribbon finish pass, over the native instrument rendered to a texture. +// p is in artboard points (1080 tall), so the layout numbers from ribbon_fx.luau line up. +// Every effect is driven by the car: parked and idle, nothing moves. +struct U { + a: vec4, // w, h, time, renderQuality (1-3) + b: vec4, // speed fill 0-1, speed trail 0-1, power fill 0-1, regen fill 0-1 + c: vec4, // red, green, yellow (smoothed 0-1), over the limit 0-1 + d: vec4, // left lamp 0-1, right lamp 0-1, seconds since left lit, seconds since right lit + e: vec4, // left gauge centre x, y, ribbon inner radius, outer radius + f: vec4, // outline radius, angle of the 0 end, angle span (radians), speed km/h + g: vec4, // car centre x, y, car top, car bottom + h: vec4, // car half width, left blind spot 0-2, right blind spot 0-2, start-up fade 0-1 + i: vec4, // energy phase left, energy phase right, road scroll (points), - + j: vec4, // left rear fan x, y, boil over the limit 0-1, - + k: vec4, // liquid: surface tilt left, right (-1..1, sloshing), ripple left, right + l: 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; +} + +// soft wide blur of the quarter-size copy: the light that spills out of bright things +fn bloom(uv: vec2, res: vec2, k: f32) -> vec3 { + let px = 4.0 / res; + var c = vec3(0.0); + var wsum = 0.0; + for (var j = -3; j <= 3; j = j + 1) { + for (var i = -3; i <= 3; i = i + 1) { + let o = vec2(f32(i), f32(j)); + let w = exp(-dot(o, o) / 6.0); + let s = textureSampleLevel(smallTex, samp, uv + o * px * k, 0.0).rgb; + let l = max(s.r, max(s.g, s.b)); + let knee = clamp((l - 0.38) / 0.30, 0.0, 1.0); // soft knee: only real highlights glow + c = c + s * knee * knee * w; + wsum = wsum + w; + } + } + return c / wsum; +} + +fn hash(p: vec2) -> f32 { + var p3 = fract(vec3(p.xyx) * 0.1031); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +// the liquid's own colour, by height on the screen: the same stops the native ribbon uses +fn liquidColour(y: f32, regen: f32) -> vec3 { + let g = clamp((942.0 - y) / 877.0, 0.0, 1.0); + var c = mix(vec3(0.851, 0.110, 0.984), vec3(0.992, 0.298, 0.741), smoothstep(0.0, 0.25, g)); + c = mix(c, vec3(0.996, 0.231, 0.812), smoothstep(0.25, 0.6, g)); + c = mix(c, vec3(0.988, 0.0, 0.984), smoothstep(0.6, 1.0, g)); + let lav = mix(vec3(0.612, 0.549, 1.0), vec3(0.788, 0.745, 1.0), g); + return mix(c, lav, regen); +} + +fn sq(x: f32) -> f32 { return x * x; } + +fn ring(r: f32, at: f32, width: f32) -> f32 { + let x = (r - at) / width; + return exp(-x * x); +} + +@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; + // geometry works in design points (2280 x 1080); the screen may be wider or narrower + let sres = vec2(2280.0, 1080.0); + let p = (uv * res - u.l.xy) / u.l.z; + + // --- lens: colour fringes grow toward the screen edges, nothing at the centre + let ed = uv - 0.5; + let fr = vec2(0.0, 0.0); // no colour fringes: clean type and scale + let base = tap(uv); + var col = vec3(tap(uv + fr).r, base.g, tap(uv - fr).b); + let lum = dot(base, vec3(0.299, 0.587, 0.114)); + let bg = 1.0 - smoothstep(0.025, 0.07, lum); // 1 on the black ground, 0 on anything drawn + + // --- bloom: neon, three radii + if (q > 1.5) { + let b = bloom(uv, res, 0.8) * 0.29 + bloom(uv, res, 2.0) * 0.25 + bloom(uv, res, 4.5) * 0.17; + col = col + b; + } + + // --- the two ribbons + for (var s = 0; s < 2; s = s + 1) { + let right = s == 1; + let qp = select(p, vec2(sres.x - p.x, p.y), right); + let d = qp - u.e.xy; + let r = length(d); + var ang = atan2(d.x, -d.y); + if (ang < 0.0) { ang = ang + 6.2831853; } + let tt = (ang - u.f.y) / u.f.z; // 0 at the zero end, 1 at the top + let ttu = select(tt, tt + 6.2831853 / u.f.z, tt < -0.5); // unwrapped across 12 o'clock + let rin = u.e.z; + let rout = u.e.w; + let band = rout - rin; + let rmid = (rin + rout) * 0.5; + // the liquid sits inside the tube: 4 px in from both walls and both end caps + let inner = clamp((rout - r) / (rout - rin), 0.0, 1.0); // 0 at the outer edge, 1 at the inner + let t0 = inner * 0.0872665 / u.f.z; // slanted zero cut: +5 deg at the inner edge + let t1 = 1.0 - inner * 0.1396263 / u.f.z; // slanted top cut: -8 deg at the inner edge + let inBand = smoothstep(rin, rin + 2.0, r) * (1.0 - smoothstep(rout - 2.0, rout, r)) * step(t0, tt) * step(tt, t1); + let fillv = select(u.b.x, max(u.b.z, u.b.w), right); + let shown = step(0.003, fillv); + let arcLen = u.f.z * rmid; + // --- the liquid: its surface tilts across the ribbon as it sloshes, and ripples + let wv = clamp((r - rmid) / (band * 0.5), -1.0, 1.0); // -1 inner edge .. 1 outer edge + let tilt = select(u.k.x, u.k.y, right); + let rip = select(u.k.z, u.k.w, right); + let surfPx = tilt * wv * band * 0.42 + + rip * (sin(wv * 3.4 + t * 6.3) * 4.5 + sin(wv * 7.9 - t * 9.1) * 2.0); + let ts = fillv + surfPx / arcLen * shown; + let depthPx = (ts - tt) * arcLen; // how far below the surface + let liquid = inBand * smoothstep(-1.2, 1.2, depthPx) * shown; + let regenMix = select(0.0, step(u.b.z, u.b.w), right); + var lc = liquidColour(p.y, regenMix); + // body: darker toward the inner wall, a glossy line along the outer third, brighter just under the surface + lc = lc * (1.0 - smoothstep(rmid, rin, r) * 0.30); + lc = lc + vec3(1.0, 0.92, 1.0) * ring(r, rmid + band * 0.24, 6.0) * 0.14; + lc = lc + vec3(1.0, 0.85, 1.0) * exp(-max(depthPx, 0.0) / 26.0) * 0.18; + // slow light moving through the liquid, at the rate the level is fed + let phase = select(u.i.x, u.i.y, right); + let sf = fract(tt * 9.0 - phase * 9.0); + lc = lc + vec3(1.0, 0.78, 1.0) * smoothstep(0.0, 0.035, sf) * (1.0 - smoothstep(0.035, 0.28, sf)) * 0.0; + // bubbles (shading after the day 15 liquid battery the boss approved): each a lit sphere, a slightly + // lighter disc that brightens toward its edge from inside, a soft glint on the side facing the surface, + // antialiased. They always rise at their own steady pace with a small sideways wobble, more of them + // drifting with the flow when the level moves, and they end at the surface. + let along = tt * arcLen; + let cellSz = 34.0; + let cj = floor((wv + 1.0) * 2.5); + // over the limit the speed liquid boils: more bubbles, rising twice as fast, shaking + let boil = select(u.j.z, 0.0, right); + // in regen the power liquid runs backwards: its bubbles sink toward the zero end + let sink = select(1.0, -1.0, right && u.b.w > u.b.z && u.b.w > 0.01); + let rise = phase * arcLen * 1.6 + sink * t * (34.0 + 26.0 * hash(vec2(cj, 3.0 + f32(s)))) * (1.0 + boil * 1.6); + let cid = floor((along - rise) / cellSz); + let hsh = hash(vec2(cid, cj + f32(s) * 13.0)); + if (hsh > 0.74 - boil * 0.30) { // the first bubble amount (boss) // a few bubbles, not foam + let wobble = sin(t * (5.0 + boil * 9.0) + hsh * 40.0) * (2.4 + boil * 3.0); + let bpos = vec2((cid + 0.5 + (hash(vec2(cid, cj + 5.0)) - 0.5) * 0.6) * cellSz + rise, + ((cj + 0.5) / 2.5 - 1.0) * band * 0.5 + wobble); + let bp = vec2(along, r - rmid) - bpos; // x toward the surface, y toward the outer edge + let br = 2.0 + hash(vec2(cid, cj + 9.0)) * 4.5; + let bd = length(bp) - br; + let cover = 1.0 - smoothstep(-0.8, 0.8, bd); + let rimIn = smoothstep(-br * 0.55, 0.0, bd); + let glint = exp(-length(bp - vec2(br * 0.35, -br * 0.35)) / max(br * 0.18, 0.6)); + let lit = mix(lc * 1.08 + vec3(0.06), vec3(1.0), clamp(rimIn * 0.5 + glint * 0.8, 0.0, 1.0)); + lc = mix(lc, lit, cover * smoothstep(3.0, 18.0, depthPx)); + } + // regen swirl: lavender streaks curl down the power liquid at the rate it charges + if (right) { + let sw = sin(along * 0.040 + wv * 5.5 + u.i.y * 40.0 + t * 1.5); + lc = lc + vec3(0.86, 0.82, 1.0) * pow(max(sw, 0.0), 8.0) * smoothstep(0.0, 0.15, u.b.w) * regenMix * smoothstep(2.0, 20.0, depthPx) * 0.0; + } + // boiling: the surface throws up a hot froth + let froth = boil * exp(-max(-depthPx, 0.0) / 6.0) * (1.0 - liquid) * inBand * shown + * smoothstep(0.55, 0.95, hash(vec2(floor(along / 5.0), floor(t * 14.0)))); + col = col + vec3(1.0, 0.75, 0.80) * froth * 0.6; + // the surface: a bright meniscus line and a little foam above it + let men = exp(-depthPx * depthPx / 3.0) * inBand * shown; + col = mix(col, lc, liquid); + col = col + vec3(1.0, 0.95, 1.0) * men * 0.72; + col = col + vec3(1.0, 0.70, 0.95) * exp(-max(-depthPx, 0.0) / 9.0) * (1.0 - liquid) * inBand * shown * 0.18; + // light off the liquid onto the ribbon's edges and a halo at the surface + let inShape = step(t0, ttu) * step(ttu, t1) * smoothstep(rin - 8.0, rin, r) * (1.0 - smoothstep(rout, rout + 8.0, r)); + col = col + liquidColour(p.y, regenMix) * (ring(r, rout + 2.0, 5.0) + ring(r, rin - 2.0, 5.0)) // glow hugs the rim + * step(tt, ts) * step(0.0, tt) * shown * 0.20 * inShape; + let ha = u.f.y + fillv * u.f.z; + let hpt = u.e.xy + rmid * vec2(sin(ha), -cos(ha)); + let hv = qp - hpt; + col = col + liquidColour(p.y, regenMix) * exp(-dot(hv, hv) / (2.0 * 60.0 * 60.0)) * 0.20 * shown * inShape; + let lit = liquid; + let pastCut = 1.0 - step(t0, ttu) * step(ttu, t1); + let nearEnd = step(-0.20, ttu) * step(ttu, 1.20); + let nearBand = smoothstep(rin - 60.0, rin - 30.0, r) * (1.0 - smoothstep(rout + 30.0, rout + 60.0, r)); + let spill = pastCut * nearEnd * nearBand; + + if (!right) { + // acceleration: the stretch gained in the last moments burns brighter + let tr = u.b.y; + if (fillv > tr + 0.003) { + let w = clamp((tt - tr) / (fillv - tr), 0.0, 1.0); + col = col + vec3(1.0, 0.62, 1.0) * lit * step(tr, tt) * w * w * 0.30; + } + // over the limit the speed ribbon runs hot red + col = mix(col, col * vec3(1.25, 0.32, 0.40) + vec3(0.22, 0.0, 0.02), lit * u.c.w * 0.75); + } else { + // regen: a lavender aura along the outside of the power ribbon + col = col + vec3(0.62, 0.52, 1.0) * u.b.w * ring(r, rout + 16.0, 24.0) * step(0.0, tt) * step(tt, 1.0) * 0.20; + } + + // blinker: a comet of amber runs down that side's outline, lit with the real lamp + let on = select(u.d.x, u.d.y, right); + let since = select(u.d.z, u.d.w, right); + let cHead = 1.0 - min(since / 0.32, 1.0); + let tail = clamp((tt - cHead) / 0.40, 0.0, 1.0); + let onLine = ring(r, u.f.x, 4.5) + ring(r, u.f.x, 20.0) * 0.45; + col = col + vec3(0.21, 0.85, 0.47) * onLine * step(cHead - 0.01, tt) * step(tt, 1.0) * (1.0 - tail) * on * exp(-since * 1.8) * 0.95; + col = mix(col, base, spill); // nothing glows past the ribbon's cut ends + } + + // --- the car: a light under it that follows power + let cx = u.g.x; + let ctop = u.g.z; + let cbot = u.g.w; + let hw = u.h.x; + let gd = vec2((p.x - cx) / (hw * 1.7), (p.y - (ctop + cbot) * 0.5) / ((cbot - ctop) * 0.62)); + let under = exp(-dot(gd, gd) * 2.2) * bg; + // (the light under the car is off: it tinted the car's glass magenta) + + // --- lane marks pass beside the car at its speed (backwards in R), fading at both ends + let ly = fract((p.y - u.i.z) / 64.0); + let dash = smoothstep(0.0, 0.04, ly) * (1.0 - smoothstep(0.42, 0.46, ly)); + let lx = min(abs(p.x - (cx - 178.0)), abs(p.x - (cx + 178.0))); + let laneV = smoothstep(250.0, 380.0, p.y) * (1.0 - smoothstep(620.0, 700.0, p.y)); // stop above the assist row + let laneC = mix(vec3(0.92, 0.70, 1.0), vec3(0.39, 0.81, 1.0), u.i.w); // blue while assist is on + col = col + laneC * exp(-lx * lx / 3.5) * dash * laneV * bg * mix(0.30, 0.55, u.i.w); + + // --- blind spots: radar pings sweep out of the car's rear corner, amber, red when urgent + for (var s = 0; s < 2; s = s + 1) { + let right = s == 1; + let fo = select(u.j.xy, vec2(sres.x - u.j.x, u.j.y), right); + let act = select(u.h.y, u.h.z, right); + if (act > 9.0) { // off: the native car in the next lane says it + let fd = p - fo; + let fl = max(length(fd), 0.001); + let dir = select(vec2(-0.80, 0.60), vec2(0.80, 0.60), right); + let cone = smoothstep(0.70, 0.93, dot(fd / fl, dir)); + let c1 = select(vec3(1.0, 0.76, 0.10), vec3(1.0, 0.23, 0.19), act > 1.5); + for (var k = 0; k < 2; k = k + 1) { + let ph = fract(t * 1.1 + f32(k) * 0.5); + col = col + c1 * ring(fl, 64.0 + ph * 120.0, 3.0 + ph * 7.0) * (1.0 - ph) * cone * min(act, 1.0) * 0.75; + } + } + } + + // --- the traffic light washes in from the top; over the limit the sign burns red + let tint = u.c.x * vec3(1.0, 0.15, 0.12) + u.c.y * vec3(0.20, 1.0, 0.45) + u.c.z * vec3(1.0, 0.75, 0.15); + // the traffic light lives in its housing now; no wash over the screen + let sd = length(p - vec2(sres.x * 0.5, 219.75)); + col = col + vec3(1.0, 0.18, 0.15) * ring(sd, 54.0, 12.0) * u.c.w * 0.38; + + // --- finish: vignette, and the start-up fade + let vq = (uv - 0.5) * vec2(1.6, 1.0); + col = col * mix(0.82, 1.0, smoothstep(1.3, 0.35, length(vq))); + col = col * 0.93; // overall a touch less bright on the phone + col = col * u.h.w; + return vec4(max(col, vec3(0.0)), 1.0); +} diff --git a/dash-apps/contest/prag-designs-parabellum/src/rive.yaml b/dash-apps/contest/prag-designs-parabellum/src/rive.yaml new file mode 100644 index 0000000..db6ac8c --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/rive.yaml @@ -0,0 +1,9 @@ +name: parabellum +main: Dash +artboard: + background: "#000000" +output: + dir: .. +logs: + file: build/rive.log + problems: build/problems.log diff --git a/dash-apps/contest/prag-designs-parabellum/src/scene.rml b/dash-apps/contest/prag-designs-parabellum/src/scene.rml new file mode 100644 index 0000000..0aede45 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/scene.rml @@ -0,0 +1,677 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dash-apps/contest/prag-designs-parabellum/src/viewmodel.part b/dash-apps/contest/prag-designs-parabellum/src/viewmodel.part new file mode 100644 index 0000000..123b2b0 --- /dev/null +++ b/dash-apps/contest/prag-designs-parabellum/src/viewmodel.part @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +