| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — Mio portraits. |
| 4 |
* |
| 5 |
* A Mio at rest, drawn as SVG by PHP. The twin of |
| 6 |
* `src/mio/portrait.ts`, and deliberately a twin rather than a shared |
| 7 |
* implementation: agents wear Mio looks, and an agent's face has to |
| 8 |
* appear where the shell bundle never loads: a comment author on the |
| 9 |
* front end, a row in the wp-admin Users list, a `get_avatar()` call |
| 10 |
* from any plugin. Those are PHP, with no JavaScript in the request at |
| 11 |
* all. |
| 12 |
* |
| 13 |
* The alternative was to render every face in the browser and post it |
| 14 |
* back, which puts a round trip on the one control in the agent wizard |
| 15 |
* that is supposed to feel instant, and still leaves the front end |
| 16 |
* with nothing to draw. |
| 17 |
* |
| 18 |
* **The two are held together by `tests/fixtures/mio-portraits.json`.** |
| 19 |
* Neither generates the other. `tests/vitest/mio-portrait.test.ts` |
| 20 |
* asserts the TypeScript side reproduces the fixture exactly, and |
| 21 |
* `Tests_OpenStation_MioPortrait` asserts this file reproduces its |
| 22 |
* structure exactly and its numbers to within a hundredth of a unit. |
| 23 |
* |
| 24 |
* The tolerance is deliberate and it is not slack. PHP and V8 do not |
| 25 |
* agree to the last bit on a chain of `pow`, `cos` and division, so a |
| 26 |
* coordinate that lands either side of a rounding boundary formats |
| 27 |
* differently for reasons that have nothing to do with the drawing. |
| 28 |
* Byte equality across two languages' floating point is not a contract |
| 29 |
* anyone can hold. A real drift (a dropped term, a wrong constant) |
| 30 |
* moves a coordinate by units, not by a hundredth. |
| 31 |
* |
| 32 |
* If you change the maths here, change it there, regenerate the fixture |
| 33 |
* with `UPDATE_MIO_PORTRAITS=1 npx vitest run mio-portrait`, and read |
| 34 |
* both diffs. |
| 35 |
* |
| 36 |
* **The output carries no text and no caller-supplied string.** Every |
| 37 |
* value written into the markup is a number computed here, and every |
| 38 |
* element name is a literal. That is a hard rule, not a style |
| 39 |
* preference: these files are written into uploads and served, so a |
| 40 |
* portrait that could carry an attacker's string would be a stored XSS |
| 41 |
* with a `.svg` extension. |
| 42 |
* |
| 43 |
* This file is NOT part of the agents module and does not sit behind |
| 44 |
* its feature flag: a portrait is a Mio capability that agents happen |
| 45 |
* to consume. |
| 46 |
* |
| 47 |
* @package OpenStation |
| 48 |
*/ |
| 49 |
|
| 50 |
defined( 'ABSPATH' ) || exit; |
| 51 |
|
| 52 |
/** Rim samples used to trace the outline. Mirrors `RIM_SAMPLES`. */ |
| 53 |
const OPENSTATION_MIO_PORTRAIT_RIM_SAMPLES = 72; |
| 54 |
|
| 55 |
/** Colour stops along the gradient. Mirrors `RING_SAMPLES`. */ |
| 56 |
const OPENSTATION_MIO_PORTRAIT_RING_SAMPLES = 16; |
| 57 |
|
| 58 |
/** |
| 59 |
* Glow shells, outermost first: `[ spread, alpha ]`. |
| 60 |
* |
| 61 |
* A glow is a dilated silhouette, not a fat outline. One wide |
| 62 |
* low-alpha band is a slab with a visible edge however wide it gets; a |
| 63 |
* ramp of concentric strokes on the same path falls off. |
| 64 |
* |
| 65 |
* @return array<int, array{0: float, 1: float}> |
| 66 |
*/ |
| 67 |
function openstation_mio_portrait_glow_shells() { |
| 68 |
return array( |
| 69 |
array( 1.0, 0.1 ), |
| 70 |
array( 0.6, 0.14 ), |
| 71 |
array( 0.28, 0.2 ), |
| 72 |
); |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* Round to 2dp the same way the TypeScript `fix()` does. |
| 77 |
* |
| 78 |
* `number_format` is not enough on its own: PHP renders a negative |
| 79 |
* value that rounds to zero as `-0.00`, and so does JavaScript's |
| 80 |
* `toFixed`, so both sides normalise it. Two renderers reaching zero |
| 81 |
* from opposite sides have to agree. |
| 82 |
* |
| 83 |
* @param float $value Value to format. |
| 84 |
* @return string Fixed 2dp representation. |
| 85 |
*/ |
| 86 |
function openstation_mio_portrait_fix( $value ) { |
| 87 |
$rounded = round( (float) $value, 2 ); |
| 88 |
if ( 0.0 === $rounded ) { |
| 89 |
$rounded = 0.0; |
| 90 |
} |
| 91 |
return number_format( $rounded, 2, '.', '' ); |
| 92 |
} |
| 93 |
|
| 94 |
/** |
| 95 |
* A 24-bit RGB int as `#rrggbb`. |
| 96 |
* |
| 97 |
* @param int $rgb Packed colour. |
| 98 |
* @return string Hex colour. |
| 99 |
*/ |
| 100 |
function openstation_mio_portrait_hex( $rgb ) { |
| 101 |
return '#' . str_pad( dechex( (int) $rgb & 0xffffff ), 6, '0', STR_PAD_LEFT ); |
| 102 |
} |
| 103 |
|
| 104 |
/** |
| 105 |
* HSL to packed 24-bit RGB. Mirrors `hslToRgbInt()` in `chroma.ts`. |
| 106 |
* |
| 107 |
* @param float $h Hue in degrees; wrapped, so -30 and 330 agree. |
| 108 |
* @param float $s Saturation, 0-1. |
| 109 |
* @param float $l Lightness, 0-1. |
| 110 |
* @return int Packed colour. |
| 111 |
*/ |
| 112 |
function openstation_mio_hsl_to_rgb_int( $h, $s, $l ) { |
| 113 |
$hue = fmod( fmod( (float) $h, 360.0 ) + 360.0, 360.0 ); |
| 114 |
$sat = min( 1.0, max( 0.0, (float) $s ) ); |
| 115 |
$lig = min( 1.0, max( 0.0, (float) $l ) ); |
| 116 |
$c = ( 1.0 - abs( 2.0 * $lig - 1.0 ) ) * $sat; |
| 117 |
$hp = $hue / 60.0; |
| 118 |
$x = $c * ( 1.0 - abs( fmod( $hp, 2.0 ) - 1.0 ) ); |
| 119 |
|
| 120 |
$r = 0.0; |
| 121 |
$g = 0.0; |
| 122 |
$b = 0.0; |
| 123 |
if ( $hp < 1 ) { |
| 124 |
$r = $c; |
| 125 |
$g = $x; |
| 126 |
} elseif ( $hp < 2 ) { |
| 127 |
$r = $x; |
| 128 |
$g = $c; |
| 129 |
} elseif ( $hp < 3 ) { |
| 130 |
$g = $c; |
| 131 |
$b = $x; |
| 132 |
} elseif ( $hp < 4 ) { |
| 133 |
$g = $x; |
| 134 |
$b = $c; |
| 135 |
} elseif ( $hp < 5 ) { |
| 136 |
$r = $x; |
| 137 |
$b = $c; |
| 138 |
} else { |
| 139 |
$r = $c; |
| 140 |
$b = $x; |
| 141 |
} |
| 142 |
|
| 143 |
$m = $lig - $c / 2.0; |
| 144 |
$to8 = static function ( $v ) use ( $m ) { |
| 145 |
// PHP rounds half away from zero and JS rounds half up. The |
| 146 |
// channel values here are never exactly .5 in practice, but |
| 147 |
// matching JS explicitly costs nothing and removes the doubt. |
| 148 |
return (int) min( 255, max( 0, floor( ( $v + $m ) * 255.0 + 0.5 ) ) ); |
| 149 |
}; |
| 150 |
return ( $to8( $r ) << 16 ) | ( $to8( $g ) << 8 ) | $to8( $b ); |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* The per-stop colour ramp. Mirrors `chromaRing()` with no hologram |
| 155 |
* (`view`), no drift (`phase`) and no spin, which is what a still |
| 156 |
* portrait has: nothing has elapsed. |
| 157 |
* |
| 158 |
* @param int $count Number of stops. |
| 159 |
* @param array $appearance Resolved appearance. |
| 160 |
* @return int[] Packed colours. |
| 161 |
*/ |
| 162 |
function openstation_mio_portrait_ring( $count, $appearance ) { |
| 163 |
$n = max( 1, (int) round( $count ) ); |
| 164 |
$out = array(); |
| 165 |
for ( $i = 0; $i < $n; $i++ ) { |
| 166 |
$t = $i / $n; |
| 167 |
// `hueLoop` walks the span out and back on a raised cosine, so |
| 168 |
// both ends of the ramp are the same colour by construction and |
| 169 |
// there is no seam. See the long note in `chroma.ts`. |
| 170 |
$shifted = fmod( fmod( $t - $appearance['hueAngle'] / 360.0, 1.0 ) + 1.0, 1.0 ); |
| 171 |
$ramp = $appearance['hueLoop'] |
| 172 |
? 0.5 - 0.5 * cos( $shifted * M_PI * 2.0 ) |
| 173 |
: $shifted; |
| 174 |
$hue = $appearance['hueStart'] + $appearance['hueSpan'] * $ramp; |
| 175 |
// Cosine hump peaking at t = 1/3: the lit side of the ring. |
| 176 |
$lift = 0.5 + 0.5 * cos( ( $t - 1.0 / 3.0 ) * M_PI * 2.0 ); |
| 177 |
$lightness = $appearance['lightness'] * ( 0.72 + 0.28 * $lift ); |
| 178 |
$out[] = openstation_mio_hsl_to_rgb_int( $hue, $appearance['saturation'], $lightness ); |
| 179 |
} |
| 180 |
return $out; |
| 181 |
} |
| 182 |
|
| 183 |
/** |
| 184 |
* The rest silhouette's deviation from a circle, at one angle. |
| 185 |
* |
| 186 |
* Mirrors `presetDeviation()` in `src/mio/shape.ts`. Every figurative |
| 187 |
* preset is authored against an "upright phase" where 0 is straight |
| 188 |
* up, because screen coordinates put -pi/2 at the top and one dropped |
| 189 |
* sign there is a shape that ships upside down. |
| 190 |
* |
| 191 |
* @param float $angle Rest angle, radians. |
| 192 |
* @param array $physics Resolved physics. |
| 193 |
* @return float Deviation. |
| 194 |
*/ |
| 195 |
function openstation_mio_preset_deviation( $angle, $physics ) { |
| 196 |
$half_pi = M_PI / 2.0; |
| 197 |
$tau = M_PI * 2.0; |
| 198 |
$upright = fmod( fmod( $angle + $half_pi, $tau ) + $tau, $tau ); |
| 199 |
|
| 200 |
$crest = static function ( $cosine, $power ) { |
| 201 |
return pow( 0.5 + 0.5 * $cosine, $power ); |
| 202 |
}; |
| 203 |
|
| 204 |
switch ( $physics['shapePreset'] ) { |
| 205 |
case 'circle': |
| 206 |
return 0.0; |
| 207 |
|
| 208 |
case 'ghost': |
| 209 |
// Authored in the raw screen angle: both terms are windowed |
| 210 |
// on sin(theta), which is already the underside. |
| 211 |
$under = max( 0.0, sin( $angle ) ); |
| 212 |
$n = 2.0 + 3.2 * $under; |
| 213 |
$c = abs( cos( $angle ) ); |
| 214 |
$s = abs( sin( $angle ) ); |
| 215 |
$square = 1.0 / pow( pow( $c, $n ) + pow( $s, $n ), 1.0 / $n ) - 1.0; |
| 216 |
$feet = -0.17 * pow( $under, 1.4 ) * cos( 6.0 * $angle ); |
| 217 |
return $square + $feet; |
| 218 |
|
| 219 |
case 'potato': |
| 220 |
return 0.16 * cos( 2.0 * $angle + 0.9 ) |
| 221 |
+ 0.095 * cos( 3.0 * $angle - 2.1 ) |
| 222 |
+ 0.036 * cos( 5.0 * $angle + 1.3 ) |
| 223 |
+ 0.019 * cos( 7.0 * $angle - 0.4 ); |
| 224 |
|
| 225 |
case 'star': |
| 226 |
return 0.58 * ( $crest( cos( 5.0 * $upright ), 3 ) - 0.3125 ); |
| 227 |
|
| 228 |
case 'flower': |
| 229 |
return 0.34 * ( $crest( cos( 6.0 * $upright ), 2 ) - 0.375 ); |
| 230 |
|
| 231 |
case 'diamond': |
| 232 |
return 0.34 * ( $crest( cos( 4.0 * $upright ), 2 ) - 0.375 ); |
| 233 |
|
| 234 |
case 'drop': |
| 235 |
return 0.72 * ( pow( max( 0.0, cos( $upright ) ), 8 ) - 0.1367 ); |
| 236 |
|
| 237 |
case 'cloud': |
| 238 |
$up = max( 0.0, cos( $upright ) ); |
| 239 |
$down = max( 0.0, -cos( $upright ) ); |
| 240 |
return 0.34 * ( |
| 241 |
sqrt( $up ) * ( 0.5 + 0.5 * cos( 5.0 * $upright ) ) |
| 242 |
- 0.7 * $down * $down |
| 243 |
- 0.0247 |
| 244 |
); |
| 245 |
|
| 246 |
case 'heart': |
| 247 |
$fold = $upright > M_PI ? $tau - $upright : $upright; |
| 248 |
$cleft = -0.34 * pow( max( 0.0, cos( $upright ) ), 6 ); |
| 249 |
$lobes = 0.3 * pow( max( 0.0, cos( $fold - 1.0 ) ), 3 ); |
| 250 |
$tip = 0.34 * pow( max( 0.0, -cos( $upright ) ), 8 ); |
| 251 |
return $cleft + $lobes + $tip + 0.02; |
| 252 |
|
| 253 |
case 'custom': |
| 254 |
$lobes = (int) round( $physics['shapeLobes'] ); |
| 255 |
if ( $lobes < 2 ) { |
| 256 |
return 0.0; |
| 257 |
} |
| 258 |
return ( 1.0 / ( 1.0 + $lobes * $lobes ) ) * cos( $lobes * $angle ); |
| 259 |
|
| 260 |
default: |
| 261 |
// 'blob': a corner up, so a flat side sits along the bottom. |
| 262 |
return 0.05 * cos( 3.0 * ( $angle + $half_pi ) ); |
| 263 |
} |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* The rest silhouette as a multiplier on the radius, at one angle. |
| 268 |
* |
| 269 |
* Mirrors `shapeProfile()`. |
| 270 |
* |
| 271 |
* @param float $angle Rest angle, radians. |
| 272 |
* @param array $physics Resolved physics. |
| 273 |
* @return float Radius multiplier. |
| 274 |
*/ |
| 275 |
function openstation_mio_shape_profile( $angle, $physics ) { |
| 276 |
if ( $physics['shapeAmount'] <= 0 ) { |
| 277 |
return 1.0; |
| 278 |
} |
| 279 |
$upright = $angle - ( $physics['shapeAngle'] * M_PI ) / 180.0; |
| 280 |
return 1.0 + $physics['shapeAmount'] * openstation_mio_preset_deviation( $upright, $physics ); |
| 281 |
} |
| 282 |
|
| 283 |
/** |
| 284 |
* The closed outline, as a cubic path centred on the origin. |
| 285 |
* |
| 286 |
* Sampled from the rest profile and joined with the Catmull-Rom to |
| 287 |
* bezier conversion the live renderer applies to its rim; sampling |
| 288 |
* alone reads as a polygon at these sizes. |
| 289 |
* |
| 290 |
* @param array $physics Resolved physics. |
| 291 |
* @param float $radius Base radius. |
| 292 |
* @return string SVG path data. |
| 293 |
*/ |
| 294 |
function openstation_mio_portrait_path( $physics, $radius ) { |
| 295 |
$n = OPENSTATION_MIO_PORTRAIT_RIM_SAMPLES; |
| 296 |
$pts = array(); |
| 297 |
for ( $i = 0; $i < $n; $i++ ) { |
| 298 |
$angle = ( $i / $n ) * M_PI * 2.0; |
| 299 |
$r = $radius * openstation_mio_shape_profile( $angle, $physics ); |
| 300 |
$pts[] = array( $r * cos( $angle ), $r * sin( $angle ) ); |
| 301 |
} |
| 302 |
|
| 303 |
$at = static function ( $i ) use ( $pts, $n ) { |
| 304 |
return $pts[ ( ( $i % $n ) + $n ) % $n ]; |
| 305 |
}; |
| 306 |
|
| 307 |
$d = 'M' . openstation_mio_portrait_fix( $pts[0][0] ) . ' ' . openstation_mio_portrait_fix( $pts[0][1] ); |
| 308 |
for ( $i = 0; $i < $n; $i++ ) { |
| 309 |
$p0 = $at( $i - 1 ); |
| 310 |
$p1 = $at( $i ); |
| 311 |
$p2 = $at( $i + 1 ); |
| 312 |
$p3 = $at( $i + 2 ); |
| 313 |
$c1x = $p1[0] + ( $p2[0] - $p0[0] ) / 6.0; |
| 314 |
$c1y = $p1[1] + ( $p2[1] - $p0[1] ) / 6.0; |
| 315 |
$c2x = $p2[0] - ( $p3[0] - $p1[0] ) / 6.0; |
| 316 |
$c2y = $p2[1] - ( $p3[1] - $p1[1] ) / 6.0; |
| 317 |
$d .= 'C' . openstation_mio_portrait_fix( $c1x ) . ' ' . openstation_mio_portrait_fix( $c1y ) |
| 318 |
. ',' . openstation_mio_portrait_fix( $c2x ) . ' ' . openstation_mio_portrait_fix( $c2y ) |
| 319 |
. ',' . openstation_mio_portrait_fix( $p2[0] ) . ' ' . openstation_mio_portrait_fix( $p2[1] ); |
| 320 |
} |
| 321 |
return $d . 'Z'; |
| 322 |
} |
| 323 |
|
| 324 |
/** |
| 325 |
* How far the outline reaches, as a multiple of the radius. |
| 326 |
* |
| 327 |
* Every preset subtracts its own mean, so they share an average radius |
| 328 |
* but not a peak: a teardrop reaches 1.62x, a star 1.40x, a circle |
| 329 |
* 1.00x. A box drawn for the circle amputates the teardrop's tip, so |
| 330 |
* the box is measured rather than assumed. |
| 331 |
* |
| 332 |
* @param array $physics Resolved physics. |
| 333 |
* @return float Peak multiplier. |
| 334 |
*/ |
| 335 |
function openstation_mio_portrait_extent( $physics ) { |
| 336 |
$n = OPENSTATION_MIO_PORTRAIT_RIM_SAMPLES; |
| 337 |
$max = 0.0; |
| 338 |
for ( $i = 0; $i < $n; $i++ ) { |
| 339 |
$max = max( $max, openstation_mio_shape_profile( ( $i / $n ) * M_PI * 2.0, $physics ) ); |
| 340 |
} |
| 341 |
return $max; |
| 342 |
} |
| 343 |
|
| 344 |
/** |
| 345 |
* Draw a Mio at rest. |
| 346 |
* |
| 347 |
* The look is taken as given: callers hand it the output of |
| 348 |
* `openstation_mio_clamp_look()`, never raw storage. |
| 349 |
* |
| 350 |
* `$id_suffix` is appended to every internal id. The markup defines |
| 351 |
* the outline and the gradient once and references them, so two |
| 352 |
* portraits inlined into one document with the same ids would both |
| 353 |
* render the first one's shape, silently. A portrait written to its |
| 354 |
* own file or used as an `img` source is its own document and needs |
| 355 |
* nothing. |
| 356 |
* |
| 357 |
* @param array $look Partial look: `appearance` and `physics`. |
| 358 |
* @param int $size Rendered width and height, in pixels. |
| 359 |
* @param string $id_suffix Appended to internal ids. |
| 360 |
* @return string SVG markup. |
| 361 |
*/ |
| 362 |
function openstation_mio_portrait_svg( $look = array(), $size = 96, $id_suffix = '' ) { |
| 363 |
$defaults = openstation_mio_default_config(); |
| 364 |
$appearance = array_merge( |
| 365 |
$defaults['appearance'], |
| 366 |
isset( $look['appearance'] ) && is_array( $look['appearance'] ) ? $look['appearance'] : array() |
| 367 |
); |
| 368 |
$physics = array_merge( |
| 369 |
$defaults['physics'], |
| 370 |
isset( $look['physics'] ) && is_array( $look['physics'] ) ? $look['physics'] : array() |
| 371 |
); |
| 372 |
|
| 373 |
// The shipped defaults write colours as CSS hex strings because |
| 374 |
// that is what reads well in a config array; a clamped look carries |
| 375 |
// them as ints. Normalise so this draws the same either way. |
| 376 |
$appearance['bodyColor'] = openstation_mio_color_int( $appearance['bodyColor'] ); |
| 377 |
$appearance['eyeColor'] = openstation_mio_color_int( $appearance['eyeColor'] ); |
| 378 |
|
| 379 |
// Only [A-Za-z0-9_-] survives, so a caller cannot close the |
| 380 |
// attribute and write markup through this parameter. |
| 381 |
$uid = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $id_suffix ); |
| 382 |
$ring_id = 'r' . $uid; |
| 383 |
$shape_id = 's' . $uid; |
| 384 |
|
| 385 |
// Work on a canonical 100-unit radius and let the viewBox scale it, |
| 386 |
// so the same path serves a 24px avatar and a 176px hero. |
| 387 |
$radius = 100.0; |
| 388 |
$stroke = $appearance['outlineWidth'] * ( $radius / $defaults['appearance']['radius'] ); |
| 389 |
$reach = ( $appearance['glow'] / 10.0 ) * $radius * 0.18; |
| 390 |
$shells = openstation_mio_portrait_glow_shells(); |
| 391 |
$half = $radius * openstation_mio_portrait_extent( $physics ) |
| 392 |
+ $stroke / 2.0 |
| 393 |
+ $reach * $shells[0][0]; |
| 394 |
|
| 395 |
$box = openstation_mio_portrait_fix( $half ); |
| 396 |
$span = openstation_mio_portrait_fix( $half * 2.0 ); |
| 397 |
$d = openstation_mio_portrait_path( $physics, $radius ); |
| 398 |
$ring = openstation_mio_portrait_ring( OPENSTATION_MIO_PORTRAIT_RING_SAMPLES, $appearance ); |
| 399 |
|
| 400 |
$stops = ''; |
| 401 |
$last = count( $ring ) - 1; |
| 402 |
foreach ( $ring as $i => $rgb ) { |
| 403 |
$offset = openstation_mio_portrait_fix( ( $i / $last ) * 100.0 ); |
| 404 |
$stops .= '<stop offset="' . $offset . '%" stop-color="' . openstation_mio_portrait_hex( $rgb ) . '"/>'; |
| 405 |
} |
| 406 |
|
| 407 |
$glow = ''; |
| 408 |
foreach ( $shells as $shell ) { |
| 409 |
list( $spread, $alpha ) = $shell; |
| 410 |
$glow .= '<use href="#' . $shape_id . '" fill="none" stroke="url(#' . $ring_id . ')"' |
| 411 |
. ' stroke-width="' . openstation_mio_portrait_fix( $stroke + $reach * $spread * 2.0 ) . '"' |
| 412 |
. ' stroke-opacity="' . openstation_mio_portrait_fix( $alpha ) . '" stroke-linejoin="round"/>'; |
| 413 |
} |
| 414 |
|
| 415 |
// At rest the body is undeformed, so every squash factor in |
| 416 |
// `eyeLayout()` is exactly 1 and the gaze and blink terms are zero. |
| 417 |
// What is left is the resting face. |
| 418 |
$eye_h = $radius * $appearance['eyeScale']; |
| 419 |
$eye_w = $eye_h * 0.46; |
| 420 |
$eye_gap = $radius * 0.28; |
| 421 |
$eye_y = -$radius * 0.02 - $eye_h / 2.0; |
| 422 |
$eye = static function ( $cx ) use ( $eye_w, $eye_h, $eye_y, $appearance ) { |
| 423 |
return '<rect x="' . openstation_mio_portrait_fix( $cx - $eye_w / 2.0 ) . '"' |
| 424 |
. ' y="' . openstation_mio_portrait_fix( $eye_y ) . '"' |
| 425 |
. ' width="' . openstation_mio_portrait_fix( $eye_w ) . '"' |
| 426 |
. ' height="' . openstation_mio_portrait_fix( $eye_h ) . '"' |
| 427 |
. ' rx="' . openstation_mio_portrait_fix( $eye_w / 2.0 ) . '"' |
| 428 |
. ' fill="' . openstation_mio_portrait_hex( $appearance['eyeColor'] ) . '"/>'; |
| 429 |
}; |
| 430 |
|
| 431 |
return '<svg xmlns="http://www.w3.org/2000/svg" width="' . (int) $size . '" height="' . (int) $size . '"' |
| 432 |
. ' viewBox="-' . $box . ' -' . $box . ' ' . $span . ' ' . $span . '">' |
| 433 |
. '<defs><linearGradient id="' . $ring_id . '" x1="0" y1="0" x2="0.85" y2="1">' . $stops . '</linearGradient>' |
| 434 |
. '<path id="' . $shape_id . '" d="' . $d . '"/></defs>' |
| 435 |
. $glow |
| 436 |
. '<use href="#' . $shape_id . '" fill="' . openstation_mio_portrait_hex( $appearance['bodyColor'] ) . '"' |
| 437 |
. ' fill-opacity="' . openstation_mio_portrait_fix( $appearance['bodyAlpha'] ) . '"' |
| 438 |
. ' stroke="url(#' . $ring_id . ')"' |
| 439 |
. ' stroke-width="' . openstation_mio_portrait_fix( $stroke ) . '" stroke-linejoin="round"/>' |
| 440 |
. $eye( -$eye_gap ) |
| 441 |
. $eye( $eye_gap ) |
| 442 |
. '</svg>'; |
| 443 |
} |
| 444 |
|