PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / mio-portrait.php

mio-portrait.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at includes/mio-portrait.php

468 lines 17.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 $appearance['linerColor'] = openstation_mio_color_int( $appearance['linerColor'] );
379
380 // Only [A-Za-z0-9_-] survives, so a caller cannot close the
381 // attribute and write markup through this parameter.
382 $uid = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $id_suffix );
383 $ring_id = 'r' . $uid;
384 $shape_id = 's' . $uid;
385 $clip_id = 'c' . $uid;
386
387 // Work on a canonical 100-unit radius and let the viewBox scale it,
388 // so the same path serves a 24px avatar and a 176px hero.
389 $radius = 100.0;
390 $scale = $radius / $defaults['appearance']['radius'];
391 $stroke = $appearance['outlineWidth'] * $scale;
392 $liner = $appearance['linerWidth'] * $scale;
393 $reach = ( $appearance['glow'] / 10.0 ) * $radius * 0.18;
394 $shells = openstation_mio_portrait_glow_shells();
395 $half = $radius * openstation_mio_portrait_extent( $physics )
396 + $stroke / 2.0
397 + $reach * $shells[0][0];
398
399 $box = openstation_mio_portrait_fix( $half );
400 $span = openstation_mio_portrait_fix( $half * 2.0 );
401 $d = openstation_mio_portrait_path( $physics, $radius );
402 $ring = openstation_mio_portrait_ring( OPENSTATION_MIO_PORTRAIT_RING_SAMPLES, $appearance );
403
404 $stops = '';
405 $last = count( $ring ) - 1;
406 foreach ( $ring as $i => $rgb ) {
407 $offset = openstation_mio_portrait_fix( ( $i / $last ) * 100.0 );
408 $stops .= '<stop offset="' . $offset . '%" stop-color="' . openstation_mio_portrait_hex( $rgb ) . '"/>';
409 }
410
411 $glow = '';
412 foreach ( $shells as $shell ) {
413 list( $spread, $alpha ) = $shell;
414 $glow .= '<use href="#' . $shape_id . '" fill="none" stroke="url(#' . $ring_id . ')"'
415 . ' stroke-width="' . openstation_mio_portrait_fix( $stroke + $reach * $spread * 2.0 ) . '"'
416 . ' stroke-opacity="' . openstation_mio_portrait_fix( $alpha ) . '" stroke-linejoin="round"/>';
417 }
418
419 // At rest the body is undeformed, so every squash factor in
420 // `eyeLayout()` is exactly 1 and the gaze and blink terms are zero.
421 // What is left is the resting face.
422 $eye_h = $radius * $appearance['eyeScale'];
423 $eye_w = $eye_h * 0.46;
424 $eye_gap = $radius * 0.28;
425 $eye_y = -$radius * 0.02 - $eye_h / 2.0;
426 $eye = static function ( $cx ) use ( $eye_w, $eye_h, $eye_y, $appearance ) {
427 return '<rect x="' . openstation_mio_portrait_fix( $cx - $eye_w / 2.0 ) . '"'
428 . ' y="' . openstation_mio_portrait_fix( $eye_y ) . '"'
429 . ' width="' . openstation_mio_portrait_fix( $eye_w ) . '"'
430 . ' height="' . openstation_mio_portrait_fix( $eye_h ) . '"'
431 . ' rx="' . openstation_mio_portrait_fix( $eye_w / 2.0 ) . '"'
432 . ' fill="' . openstation_mio_portrait_hex( $appearance['eyeColor'] ) . '"/>';
433 };
434
435 // The inner line, clipped to the body.
436 //
437 // SVG strokes are centred on their path and cannot be offset to one
438 // side, so the line is drawn at the full width it would need if it
439 // reached both ways — `stroke + liner * 2` — and the clip throws
440 // the outer half away. What is left runs from the outline inward,
441 // and the chroma stroke below is painted over its inner reach, so
442 // the visible white is exactly the band between the two. That is
443 // the same geometry `fillLiner()` produces in the live renderer, by
444 // the only means SVG offers.
445 $line = '';
446 if ( $liner > 0 ) {
447 $line = '<use href="#' . $shape_id . '" fill="none"'
448 . ' stroke="' . openstation_mio_portrait_hex( $appearance['linerColor'] ) . '"'
449 . ' stroke-width="' . openstation_mio_portrait_fix( $stroke + $liner * 2.0 ) . '"'
450 . ' stroke-linejoin="round" clip-path="url(#' . $clip_id . ')"/>';
451 }
452
453 return '<svg xmlns="http://www.w3.org/2000/svg" width="' . (int) $size . '" height="' . (int) $size . '"'
454 . ' viewBox="-' . $box . ' -' . $box . ' ' . $span . ' ' . $span . '">'
455 . '<defs><linearGradient id="' . $ring_id . '" x1="0" y1="0" x2="0.85" y2="1">' . $stops . '</linearGradient>'
456 . '<path id="' . $shape_id . '" d="' . $d . '"/>'
457 . '<clipPath id="' . $clip_id . '"><use href="#' . $shape_id . '"/></clipPath></defs>'
458 . $glow
459 . '<use href="#' . $shape_id . '" fill="' . openstation_mio_portrait_hex( $appearance['bodyColor'] ) . '"'
460 . ' fill-opacity="' . openstation_mio_portrait_fix( $appearance['bodyAlpha'] ) . '"/>'
461 . $line
462 . '<use href="#' . $shape_id . '" fill="none" stroke="url(#' . $ring_id . ')"'
463 . ' stroke-width="' . openstation_mio_portrait_fix( $stroke ) . '" stroke-linejoin="round"/>'
464 . $eye( -$eye_gap )
465 . $eye( $eye_gap )
466 . '</svg>';
467 }
468