diff --git a/dash-apps/contest/prag-designs-felina/README.md b/dash-apps/contest/prag-designs-felina/README.md new file mode 100644 index 00000000..c0393104 --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/README.md @@ -0,0 +1,45 @@ +# Felina + +**Category:** Rive +**File:** [`felina.riv`](felina.riv) (about 260 KB), built from [`src/`](src/) + +![Felina](preview.jpg) + +Felina is a night-drive instrument cluster under glass. The name is Fe, Li and Na: iron for the car, lithium for the battery it reads, and sodium for the amber streetlight glow it is lit with. One big dial holds the speed: large digits in the hub, a needle with a fading light trail, the speed limit as a red mark on the scale, and the cruise set speed as a blue one. Two smaller dials sit beside it. On the left, battery charge and a power gauge that swings toward POWER or REGEN. On the right, your car seen from above, with cars in your blind spots, lane marks that pass at your speed, and the assist state. The light comes from the car's data. A traffic light floods the glass red, amber or green. The blinker throws amber light in from that side of the screen, timed to the real lamp. Over the limit, the scale between the limit mark and the needle glows hot. Regen sends energy streaming along the battery arc. When the dash starts, the dials push in, the readouts count up and the needle sweeps the whole scale before it settles. + +## What it shows + +| Data | How | +|---|---| +| `egoSpeed` | the big digits in the hub, and the needle on a 0 to 160 scale. It also sets how fast the lane marks pass | +| `isImperial` | KM/H or MPH under the digits | +| `fusedSpeedLimit` | the limit in a red ring above the digits and a red mark on the scale; hidden while unknown. Over it, the gap glows and a red +N appears | +| `accSetSpeed`, `adasOn` | a blue mark on the scale and SET under the digits; ASSIST ON or MANUAL in the right dial, and a cool light on the hub rim | +| `gear` | P R N D under the digits, the current one lit. In P the hub shows a large P and PARKED; in R a white reverse light rises along the bottom of the dial with REVERSE | +| `powerLevel`, `packVoltage`, `packCurrent` | the power gauge in the left dial with kW, and pack voltage in the bottom bar | +| `nominalEnergyRemaining`, `fullPackEnergy`, `energyBuffer` | battery % and the charge arc in the left dial | +| `leftBlinker`, `rightBlinker` | a green arrow beside the dial lit in step with the lamp, and amber light sweeping in from that edge, with a bulb-like afterglow | +| `leftBlindSpot`, `rightBlindSpot` | a car in the next lane in the right dial and a glow on that screen edge: amber for a vehicle, red when urgent. Shown whenever the car reports it | +| `egoSteeringAngle` | your car in the right dial turns with the wheel, and the reflection on the glass slides | +| `changingLane` | CHANGING LANE under the assist state | +| `trafficLightColor`, `stopLineDist` | three lamps in the right dial, the glass tinted the same colour, and the distance to the stop line | +| `anyDoorOpen`, `buckleStatus` | DOOR OPEN and SEATBELT in the top bar | +| `currentTime`, `phoneBattery`, `dataSourceType` | clock, phone battery (`showPhoneBattery`) and the data source in the top bar | +| `odometer` | odometer in the bottom bar (`showOdometer`) | +| `renderQuality` | the glass pass skips its bloom at the lowest setting | + +The main artboard is built from layouts and tested from 1440 (4:3) to 2340 (19.5:9) units wide. The three dials live in their own `Gauges` artboard, placed with fit `scaleDown`: full size on a phone, shrunk as a whole on a tablet. + +## How it is built + +Everything you see is native Rive: shapes, text and data binds on `DashViewModel`. A scripted layout (`src/felina_fx.luau`) then draws a private copy of that instrument into a texture each frame and finishes it in a WGSL pass (`src/nightglass.wgsl`): bloom, glass, the needle trail and the lights listed above. While the script runs it sets `fxActive`, which hides the native copy underneath. If a runtime cannot run scripts, nothing in that layer draws and the native dash shows on its own, with every reading still live. + +- `src/gen.py` writes `src/scene.rml` from `viewmodel.part`, `converters.part` and `mine.part`. 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`, `fxWidth`, `laneScroll`, `intro*`). +- Rebuild: `cd src && python3 gen.py && rive . --publish`. This writes `../felina.riv` with the two scripts signed. `rive . --once` builds without signing. + +## Credits + +- **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 subset files `rive/dash-demo/` already ships. +- **Car** (`src/car.png`, the top-down car in the right dial, also tinted for the blind-spot cars): made for this entry with OpenAI's image model, no reference image or third-party artwork. Released under this repository's MIT license with the rest of the entry. +- Everything else (dials, scales, needle, 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-felina/felina.riv b/dash-apps/contest/prag-designs-felina/felina.riv new file mode 100644 index 00000000..83dcd8b6 Binary files /dev/null and b/dash-apps/contest/prag-designs-felina/felina.riv differ diff --git a/dash-apps/contest/prag-designs-felina/preview.jpg b/dash-apps/contest/prag-designs-felina/preview.jpg new file mode 100644 index 00000000..4251d2c0 Binary files /dev/null and b/dash-apps/contest/prag-designs-felina/preview.jpg differ diff --git a/dash-apps/contest/prag-designs-felina/src/.gitignore b/dash-apps/contest/prag-designs-felina/src/.gitignore new file mode 100644 index 00000000..3d433392 --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/src/.gitignore @@ -0,0 +1,2 @@ +build/ +__pycache__/ diff --git a/dash-apps/contest/prag-designs-felina/src/car.png b/dash-apps/contest/prag-designs-felina/src/car.png new file mode 100644 index 00000000..01c3807e Binary files /dev/null and b/dash-apps/contest/prag-designs-felina/src/car.png differ diff --git a/dash-apps/contest/prag-designs-felina/src/converters.part b/dash-apps/contest/prag-designs-felina/src/converters.part new file mode 100644 index 00000000..eca31e2a --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/src/converters.part @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dash-apps/contest/prag-designs-felina/src/felina_fx.luau b/dash-apps/contest/prag-designs-felina/src/felina_fx.luau new file mode 100644 index 00000000..c466c84d --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/src/felina_fx.luau @@ -0,0 +1,247 @@ +-- Felina GPU layer. Renders the native Instrument artboard (bound to DashPilot's live view +-- model) into a texture every frame and finishes it in nightglass.wgsl: bloom, glass, a lens over the +-- hub, and light that comes from the car's data (traffic light, blinker lamps, regen, assist). +-- 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 light state + red: number, green: number, yellow: number, regen: number, assist: number, steer: number, + lPrev: number, rPrev: number, lT: number, rT: number, lOn: number, rOn: number, + needle: number, trail: number, gearPrev: number, engT: number, lane: number, introDone: boolean, sweep: number, glass: number, +} + +local MAXW = 2400 -- cap the texture width; phones report 3x scale +local DIAL_Y, DIAL_R, HUB_R = 542, 440, 282 +local BATT_X, BATT_Y, BATT_R = 348, 582, 200 +local SAT_X, SAT_Y, SAT_R = 348, 582, 268 -- left satellite; the right one mirrors from the right edge +-- those are full-size positions (2280 wide). Below 2236 the Gauges artboard (2140 x 944, from x 48, y 70) +-- is shrunk by scaleDown and centred in Main, so the shader's positions follow it through px() and py() +local GW, GH, GX, GY = 2140, 944, 48, 70 + +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 flag(vm: ViewModel, name: string): number + local p = vm:getBoolean(name) + if p and p.value then return 1 end + return 0 +end + +-- the dial's own mapping: 0 km/h at 7:30 (-135 deg), 160 at 4:30 (+135 deg) +local function gaugeAngle(v: number): number + return math.rad(-135 + math.clamp(v, 0, 160) / 160 * 270) +end + +local function easeOut(x: number): number + local c = math.clamp(x, 0, 1) + return 1 - (1 - c) ^ 3 +end + +local function easeInOut(x: number): number + local c = math.clamp(x, 0, 1) + return c < 0.5 and 4 * c ^ 3 or 1 - (-2 * c + 2) ^ 3 / 2 +end + +local function setNum(vm: ViewModel, name: string, v: number) + local p = vm:getNumber(name) + if p then p.value = v end +end + +-- start-up: the centre dial pushes in and fades up, the side dials a beat later, the bars last; +-- readouts count up from zero while the needle sweeps the whole scale and settles back +local function intro(self: Fx, vm: ViewModel) + if self.introDone then return end + local T = self.t - 0.2 + local up = easeInOut((T - 0.25) / 0.65) + local down = easeInOut((T - 1.0) / 0.75) + self.sweep = math.rad(270) * (up - down) + self.glass = easeOut(T / 0.7) + setNum(vm, 'introMain', self.glass) + setNum(vm, 'introSide', easeOut((T - 0.12) / 0.7)) + setNum(vm, 'introBars', easeOut((T - 0.3) / 0.6)) + setNum(vm, 'introCount', easeOut((T - 0.2) / 1.2)) + setNum(vm, 'introSweep', self.sweep) + if T > 1.9 then + self.introDone = true + self.sweep = 0 + self.glass = 1 + setNum(vm, 'introSweep', 0) + end +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('nightglass') + if sh == nil then + print('felina_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 = 128, 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 + for _, n in { 'introMain', 'introSide', 'introBars', 'introCount' } do setNum(vm, n, 0) end + 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, inst = self.full, self.small, self.out, self.inst + 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) + -- setting inst.width does not lay the instance out again; its Root width is bound to fxWidth instead + local vm = self.vm + if vm then setNum(vm, 'fxWidth', self.w) end + if inst then inst:advance(0) end + self.group = nil -- textures changed size: rebuild the bind group + 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 + intro(self, vm) + local light = num(vm, 'trafficLightColor') + -- lights come in over ~250 ms and leave over ~200 ms (exponential ease-out) + local function fade(cur: number, target: number): number + return ease(cur, target, target > cur and 12 or 15, dt) + end + 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.regen = fade(self.regen, math.clamp(-num(vm, 'powerLevel') / 100, 0, 1)) + self.assist = fade(self.assist, flag(vm, 'adasOn')) + self.steer = ease(self.steer, num(vm, 'egoSteeringAngle'), 6, dt) + -- blinkers: remember when the real lamp lights, so a sweep starts 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) -- fast attack, slow decay + self.rOn = ease(self.rOn, rb == 2 and 1 or 0, rb == 2 and 30 or 6, dt) + -- needle angle (radians, 0 at 12 o'clock, clockwise): the trail is a slower copy of it + local target = gaugeAngle(num(vm, 'egoSpeed')) + self.sweep + self.needle = ease(self.needle, target, 18, dt) + self.trail = ease(self.trail, self.needle, 3.5, dt) + -- engage: shifting into D from anything else fires a pulse + local gear = num(vm, 'gear') + -- lane marks scroll past at the speed (backwards in R): ~2 px/s per km/h reads as motion without strobing + local dir = gear == 2 and -1 or 1 + self.lane = (self.lane + num(vm, 'egoSpeed') * 2 * dir * dt) % 18 + local lp = vm:getNumber('laneScroll') + if lp then lp.value = self.lane end + if gear == 4 and self.gearPrev ~= 4 and self.gearPrev ~= 0 then self.engT = self.t end + if gear ~= 0 then self.gearPrev = gear end + return true +end + +local function paint(canvas: Canvas, inst: Artboard, k: number) + local r = canvas:beginFrame({ clearColor = Color.rgba(0, 0, 0, 255) }) + r:save() + local m = Mat2D.identity() + m.xx, m.yy = k, k + 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 + paint(full, inst, self.k) + paint(small, inst, self.k / 4) + 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 speed, limit = num(vm, 'egoSpeed'), num(vm, 'fusedSpeedLimit') + local nom, fullE, bufE = num(vm, 'nominalEnergyRemaining'), num(vm, 'fullPackEnergy'), num(vm, 'energyBuffer') + local charge = 0.69 + if fullE - bufE > 0.1 then charge = math.clamp((nom - bufE) / (fullE - bufE), 0, 1) 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) + put(4, speed); put(5, self.steer); put(6, limit > 0 and (speed - limit) or -1); put(7, self.assist) + put(8, self.red); put(9, self.green); put(10, self.yellow); put(11, self.regen) + put(12, self.lOn); put(13, self.rOn); put(14, self.t - self.lT); put(15, self.t - self.rT) + local s = math.min(1, (self.w - 2 * GX) / GW, (self.h - GY - 66) / GH) + local x0 = self.w / 2 - GW * s / 2 + local y0 = GY + GH * (1 - s) / 2 + local function px(x: number): number return x0 + (x - GX) * s end + local function py(y: number): number return y0 + (y - GY) * s end + put(16, self.w / 2); put(17, py(DIAL_Y)); put(18, DIAL_R * s); put(19, HUB_R * s) + put(20, px(BATT_X)); put(21, py(BATT_Y)); put(22, BATT_R * s); put(23, charge) + put(24, self.needle); put(25, self.trail); put(26, limit > 0 and gaugeAngle(limit) or 9); put(27, self.t) + put(28, self.glass); put(29, px(SAT_X)); put(30, py(SAT_Y)); put(31, SAT_R * s) + 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(128), + samp = nil, img = ImageSampler('clamp', 'clamp', 'bilinear'), + w = 0, h = 0, k = 1, t = 0, ready = false, scale = 1, sizedW = 0, + red = 0, green = 0, yellow = 0, regen = 0, assist = 0, steer = 0, + lPrev = 0, rPrev = 0, lT = -10, rT = -10, lOn = 0, rOn = 0, + needle = math.rad(-135), trail = math.rad(-135), gearPrev = 0, engT = -10, lane = 0, introDone = false, sweep = 0, glass = 0, + init = init, resize = resize, advance = advance, draw = draw, + } +end diff --git a/dash-apps/contest/prag-designs-felina/src/fonts/inter-400.ttf b/dash-apps/contest/prag-designs-felina/src/fonts/inter-400.ttf new file mode 100644 index 00000000..3dda73d3 Binary files /dev/null and b/dash-apps/contest/prag-designs-felina/src/fonts/inter-400.ttf differ diff --git a/dash-apps/contest/prag-designs-felina/src/fonts/inter-600.ttf b/dash-apps/contest/prag-designs-felina/src/fonts/inter-600.ttf new file mode 100644 index 00000000..6dc4fdf8 Binary files /dev/null and b/dash-apps/contest/prag-designs-felina/src/fonts/inter-600.ttf differ diff --git a/dash-apps/contest/prag-designs-felina/src/gen.py b/dash-apps/contest/prag-designs-felina/src/gen.py new file mode 100644 index 00000000..fe1d2c64 --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/src/gen.py @@ -0,0 +1,597 @@ +"""Felina 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", "fxWidth": "1:87", +} +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 + # the track covers the scale only (0 to 160, 270 deg); the open bottom is left clear for P R N D + k.append(shape("Scale Track", ellipse(2 * (R - 66)) + stroke(TRACK, 1.5, trim(0, SWEEP / (2 * math.pi))), cx, cy, SW0)) + 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 carry the numbers' circle on through the open bottom of the scale, P on the left; + # upright like the numbers, the lit letter with an orange dot just outside it + for i, (g, conv) in enumerate((("P", "2:11"), ("R", "2:12"), ("N", "2:13"), ("D", "2:14"))): + a = math.radians(207 - i * 18) # 207, 189, 171, 153 deg: 18 deg clear of 0 and 160 + gx, gy = polar(cx, cy, R - 108, a) + dx, dy = polar(cx, cy, R - 66, a) # the dot sits on the scale track's line + k.append(text(f"Gear {g} Dim", [(g, "")], gx, gy, 40, INK3, MED, w=70)) + k.append(node(f"Gear {g} On", [ + text(f"Gear {g} Lit", [(g, "")], gx, gy, 40, INK, MED, w=70), + shape(f"Gear {g} Dot", ellipse(10) + fill(ORANGE, ""), dx, dy), + shape(f"Gear {g} Dot Glow", ellipse(18) + fill("88FF8A1F", glow(8)), dx, dy), + ], 0, 0, 0, bind("gear", OPACITY, conv))) + # turn signals: a green arrow in each top corner beside the dial, lit in step with the real lamp + # (fast attack, a short bulb afterglow); a faint outline stays when it is off + arrow = [(-30, 0), (-4, -24), (-4, -11), (28, -11), (28, 11), (-4, 11), (-4, 24)] + for side, prop in ((-1, "lblink"), (1, "rblink")): + pts = [(side * -x * 1.3, y * 1.3) for x, y in arrow] # the left arrow points left, the right one right + ax, ay = cx + side * 372, cy - 368 + nm = "Left" if side < 0 else "Right" + k.append(shape(f"Turn {nm} Off", poly(pts) + fill("1FF6EFE4"), ax, ay)) + k.append(node(f"Turn {nm} Afterglow", [shape(f"Turn {nm} Halo", poly(pts) + fill("8839D46E", glow(18)))], + ax, ay, 0, bind(prop, OPACITY, "8:69"))) + k.append(node(f"Turn {nm} On", [shape(f"Turn {nm} Lamp", poly(pts) + fill(GREEN))], ax, ay, 0, bind(prop, OPACITY, "8:68"))) + 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 = """ + + + + + + + + + """ + + +GW, GH = 2140, 944 # the three gauges at full size; the Main box between the bars is 944 tall + + +def gauges(): + """The three gauges in their own artboard. Main shows it through a NestedArtboardLeaf with fit + scaleDown: full size from 2236 wide up, shrunk as a whole below that (1440 = 4:3 tablet). + A plain nested artboard reads the parent's DashViewModel, so every bind still works.""" + row = lc("Gauges Row", energy_well() + speed_dial() + assist_well(), None, None, "row", "spaceBetweenCenter") + return f''' + + {row} + {SM.format(s=" G", a="6:24", n="Gauges", m="6:25", l="6:26", st="6:27")} + ''' + + +def content(): + leaf = f'' + # absolute 100% box: the leaf's own 2140 width must not set Main's minimum, or Root overflows on a tablet + sid = nid() + slot = (f'' + f'{leaf}') + main = lc("Main", slot) + 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''' + {bind("fxWidth", 7)} + + + {edges()} + {content()} + {SM.format(s=" I", a="6:4", n="Instrument", m="6:5", l="6:6", st="6:7")} + ''' + return f''' + + +{dash} +{inst} +{gauges()} + +''' + + +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-felina/src/mine.part b/dash-apps/contest/prag-designs-felina/src/mine.part new file mode 100644 index 00000000..2a79113d --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/src/mine.part @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dash-apps/contest/prag-designs-felina/src/nightglass.wgsl b/dash-apps/contest/prag-designs-felina/src/nightglass.wgsl new file mode 100644 index 00000000..c8ec07af --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/src/nightglass.wgsl @@ -0,0 +1,191 @@ +// Felina finish pass, over the native instrument rendered to a texture. +// Units: p is in artboard points (1080 tall), so the layout numbers from felina_fx.luau line up. +struct U { + a: vec4, // w, h, time, renderQuality (1-3) + b: vec4, // speed, steer (deg, smoothed), over-limit (speed - limit, -1 = unknown), assist 0-1 + c: vec4, // red, green, yellow (smoothed 0-1), regen 0-1 + d: vec4, // left lamp 0-1, right lamp 0-1, seconds since left lit, seconds since right lit + e: vec4, // dial centre x, y, outer radius, hub radius + f: vec4, // battery arc centre x, y, radius, charge 0-1 + g: vec4, // needle angle, trailing angle, limit angle (9 = none), seconds since start + h: vec4, // start-up fade 0-1 (the glass arrives with the dials), left satellite x, satellite y, satellite radius +}; +@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 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); +} + +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.42) / 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 vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), w.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), w.x), w.y); +} + +// angle of a point around the dial: 0 at 12 o'clock, clockwise positive, same as the needle +fn dialAngle(d: vec2) -> f32 { return atan2(d.x, -d.y); } + +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; + let p = uv * res; + + let c = u.e.xy; + let R = u.e.z; + let hub = u.e.w; + let d = p - c; + let r = length(d); + + let a = dialAngle(d); + let needleA = u.g.x; + let trailA = u.g.y; + + // --- over the limit, hot air rises off the scale between the limit mark and the needle + let over = clamp(u.b.z / 20.0, 0.0, 1.0); + let lo = min(u.g.z, needleA); + let hi = max(u.g.z, needleA); + let heatArc = smoothstep(lo - 0.04, lo + 0.04, a) * (1.0 - smoothstep(hi - 0.02, hi + 0.06, a)) * step(u.g.z, 8.0); + let heatBand = exp(-pow((r - (R - 70.0)) / 75.0, 2.0)); + let heat = over * heatArc * heatBand * step(0.0, u.b.z); + let hn = vec2(vnoise(p * 0.035 + vec2(0.0, t * 2.2)), vnoise(p * 0.035 + vec2(17.0, t * 2.6))); + let shimmer = (hn - 0.5) * heat * 4.0; + let ps = p + shimmer; + let uvs = ps / res; + + // the speed stays crisp: no lens, straight sample + var col = tap(uvs); + + // --- bloom: two radii, warm + if (q > 1.5) { + let b = bloom(uv, res, 0.8) * 0.35 + bloom(uv, res, 2.0) * 0.30 + bloom(uv, res, 4.5) * 0.20; + col = col + b * vec3(1.04, 0.93, 0.82); + } + + // --- dial glass: fresnel at the rim and a reflection that slides as the car steers + let inDial = 1.0 - smoothstep(R - 10.0, R, r); + let fres = smoothstep(R * 0.78, R, r) * inDial; + let glass = u.h.x; + col = col + vec3(0.9, 0.85, 0.8) * fres * 0.045 * glass; + let streak = 0.0; + // hub glass: a soft specular from the top left + let hs = d - vec2(-0.32, -0.42) * hub; + let inHub = 1.0 - smoothstep(hub - 3.0, hub, r); + col = col + vec3(1.0) * exp(-dot(hs, hs) / (2.0 * pow(hub * 0.22, 2.0))) * 0.035 * inHub * glass; + + // --- the satellites are glass too: fresnel at the rim and a soft specular + for (var sidx = 0; sidx < 2; sidx = sidx + 1) { + let sx = select(u.h.y, res.x - u.h.y, sidx == 1); + let sd = p - vec2(sx, u.h.z); + let sr = length(sd); + let sIn = 1.0 - smoothstep(u.h.w - 22.0, u.h.w - 12.0, sr); + col = col + vec3(0.9, 0.85, 0.8) * smoothstep(u.h.w * 0.72, u.h.w - 14.0, sr) * sIn * 0.035 * glass; + let sh = sd - vec2(-0.34, -0.46) * u.h.w; + col = col + vec3(1.0) * exp(-dot(sh, sh) / (2.0 * pow(u.h.w * 0.20, 2.0))) * 0.045 * sIn * glass; + } + + // --- the traffic light floods the glass from above + let tint = u.c.x * vec3(1.0, 0.16, 0.08) + u.c.y * vec3(0.20, 1.0, 0.45) + u.c.z * vec3(1.0, 0.72, 0.15); + col = col + tint * ring(r, R - 2.0, 9.0) * 0.22; + + // --- blinkers: when the real lamp lights, amber light sweeps in from that side of the screen + // blinkers: when the real lamp lights, a comet of amber light runs from 12 o'clock down the bezel on that side + let amber = vec3(1.0, 0.60, 0.12); + let bez = ring(r, R - 2.0, 10.0) + ring(r, R + 4.0, 28.0) * 0.4; + let lHead = -min(u.d.z / 0.30, 1.0) * 2.9; // runs counter-clockwise, left + let lTail = clamp((a - lHead) / 1.2, 0.0, 1.0) * step(a, 0.0) * step(lHead, a); + col = col + amber * bez * (1.0 - lTail) * step(a, 0.02) * step(lHead - 0.02, a) * exp(-u.d.z * 2.4) * 0.45; + let rHead = min(u.d.w / 0.30, 1.0) * 2.9; // clockwise, right + let rTail = clamp((rHead - a) / 1.2, 0.0, 1.0) * step(0.0, a) * step(a, rHead); + col = col + amber * bez * (1.0 - rTail) * step(-0.02, a) * step(a, rHead + 0.02) * exp(-u.d.w * 2.4) * 0.45; + col = col + amber * (u.d.x * exp(-p.x / 120.0) + u.d.y * exp(-(res.x - p.x) / 120.0)) * 0.08; + + // --- regen: energy streams along the battery arc into the charge + let bd = p - u.f.xy; + let br = length(bd); + let ba = atan2(bd.x, -bd.y); // 0 at the top, -pi/2 left, +pi/2 right + let along = (ba + 1.3089969) / 2.6179939; // battery arc sweeps -75..+75 deg + let onArc = step(-1.3089969, ba) * step(ba, 1.3089969) * step(along, u.f.w); + let arcBand = ring(br, u.f.z, 7.0); + let green = vec3(0.30, 1.0, 0.55); + var sparks = 0.0; + for (var k = 0; k < 3; k = k + 1) { + let lane = f32(k); + let s = along * 34.0 + lane * 0.37 - t * (1.6 + lane * 0.5); + let cell = floor(s); + let jit = (hash(vec2(cell, lane)) - 0.5) * 12.0; + let fx = fract(s) - 0.5; + sparks = sparks + exp(-fx * fx * 90.0) * exp(-pow((br - u.f.z - jit) / 3.2, 2.0)) * step(0.55, hash(vec2(cell, lane + 7.0))); + } + col = col + green * u.c.w * onArc * (sparks * 0.8 + arcBand * 0.15); + + // --- assist: a cool light rises on the hub rim + col = col + vec3(0.35, 0.62, 1.0) * u.b.w * ring(r, hub, 7.0) * 0.12; + + // --- over the limit: the heat glows, flickering as it rises + col = col + vec3(1.0, 0.25, 0.08) * heat * 0.10; + + // --- needle light trail: a fading wedge between where the needle was and where it is + let span = needleA - trailA; + if (abs(span) > 0.004) { + let w = clamp((a - trailA) / span, 0.0, 1.0); + let inSpan = step(0.0, (a - trailA) * sign(span)) * step(0.0, (needleA - a) * sign(span)); + let band = smoothstep(hub + 4.0, hub + 30.0, r) * (1.0 - smoothstep(R - 50.0, R - 22.0, r)); + let strength = clamp(abs(span) * 2.5, 0.0, 1.0); + col = col + vec3(1.0, 0.48, 0.12) * inSpan * band * pow(w, 2.2) * strength * 0.55; + } + + + // --- finish: vignette and a little grain, so the black reads as glass, not void + let vq = (uv - 0.5) * vec2(1.7, 1.0); + col = col * mix(0.86, 1.0, smoothstep(1.3, 0.4, length(vq))); + return vec4(max(col, vec3(0.0)), 1.0); +} diff --git a/dash-apps/contest/prag-designs-felina/src/rive.yaml b/dash-apps/contest/prag-designs-felina/src/rive.yaml new file mode 100644 index 00000000..3d855e1c --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/src/rive.yaml @@ -0,0 +1,9 @@ +name: felina +main: Dash +artboard: + background: "#000000" +output: + dir: .. +logs: + file: build/rive.log + problems: build/problems.log diff --git a/dash-apps/contest/prag-designs-felina/src/scene.rml b/dash-apps/contest/prag-designs-felina/src/scene.rml new file mode 100644 index 00000000..d8ee9434 --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/src/scene.rml @@ -0,0 +1,611 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dash-apps/contest/prag-designs-felina/src/viewmodel.part b/dash-apps/contest/prag-designs-felina/src/viewmodel.part new file mode 100644 index 00000000..6bfc1931 --- /dev/null +++ b/dash-apps/contest/prag-designs-felina/src/viewmodel.part @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +