component
Poster
A wall print of seeded flat art (shapes, color bands, a retro sunset, mountain peaks) with an optional caption and a slim or wide frame.
⌖ Explore the library in 3D → · View as Markdown
Add it
npx @runek/cli add poster
Pulls sign, @runek/core@^0.15.0, three@^0.184.0.
Use it
import { Poster } from './runek/Poster'
<Poster position={[0, 0, 0]} seed={1} />
Or as a node in a world file (worlds as data):
{ "type": "Poster", "props": { "position": [0, 0, 0], "seed": 1 } }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
size |
[number, number] |
[0.5, 0.7] |
The print, [width, height] in units. |
art |
"shapes" | "bands" | "sunset" | "peaks" |
Seeded when unset. | |
frame |
"none" | "thin" | "wide" |
Seeded when unset. | |
title |
string |
A caption printed under the art. | |
frameColor |
color |
The frame; defaults to the palette’s woodDark. |
|
paper |
color |
"#f4f1ea" |
The paper behind the art and the caption. |
ink |
color |
"#222222" |
The caption’s ink. |
texture |
Texture (code only) |
JSX only: a texture the app owns, printed in place of the seeded art. The poster loads nothing itself. | |
position |
[x, y, z] |
[0, 0, 0] |
Position [x, y, z] in units (1 unit = 1 m, Y-up). |
rotation |
[x, y, z] |
[0, 0, 0] |
Euler rotation [x, y, z] in radians. |
seed |
number |
1 |
Seed for the deterministic variation: same seed, same result. |
Registry manifest
registry manifest /r/components/poster.json Self-contained JSON: inlined source plus resolved dependencies, exactly whatrunek add poster fetches.
Browse the whole catalog in the gallery →.
Source (Poster.tsx)
import { pick, range, rng, sub, useWorld, type WorldComponentProps } from '@runek/core'
import { useMemo } from 'react'
import type { Texture } from 'three'
import { Sign } from './Sign'
/** The print: overlapping `shapes`, colored `bands`, a retro `sunset` over the sea, or `peaks`. */
export type PosterArt = 'shapes' | 'bands' | 'sunset' | 'peaks'
/** No frame, a slim one, or a wide one with a mat. */
export type PosterFrame = 'none' | 'thin' | 'wide'
export interface PosterProps extends WorldComponentProps {
/** The print, [width, height] in units. */
size?: [number, number]
/** Seeded when unset. */
art?: PosterArt
/** Seeded when unset. */
frame?: PosterFrame
/** A caption printed under the art. */
title?: string
/** The frame; defaults to the palette's `woodDark`. */
frameColor?: string
/** The paper behind the art and the caption. */
paper?: string
/** The caption's ink. */
ink?: string
/** JSX only: a texture the app owns, printed in place of the seeded art. The poster loads
* nothing itself. */
texture?: Texture
}
type Kind = 'rect' | 'circle' | 'tri'
interface Shape {
kind: Kind
x: number
y: number
w: number
h: number
color: string
}
const ARTS: PosterArt[] = ['shapes', 'bands', 'sunset', 'peaks']
const FRAMES: PosterFrame[] = ['none', 'thin', 'thin', 'wide']
const PALETTES = [
['#e4572e', '#f3a712', '#29335c', '#a8c686', '#f0ead6'],
['#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'],
['#1d3557', '#457b9d', '#a8dadc', '#f1faee', '#e63946'],
['#3d405b', '#81b29a', '#f2cc8f', '#e07a5f', '#f4f1de'],
['#2b2d42', '#8d99ae', '#edf2f4', '#ef233c', '#d90429'],
]
/** Shapes in art-local meters (centered, W by H), each inside the art area. */
function paint(art: PosterArt, seed: number, W: number, H: number): Shape[] {
const r = rng(sub(seed, 1))
const colors = pick(r, PALETTES)
const c = (i: number) => colors[i % colors.length]
const out: Shape[] = []
const m = Math.min(W, H)
if (art === 'bands') {
const n = 4 + Math.floor(r() * 5)
let y = -H / 2
const weights = Array.from({ length: n }, () => range(r, 0.5, 1.5))
const total = weights.reduce((a, b) => a + b, 0)
weights.forEach((wt, i) => {
const h = (wt / total) * H
out.push({ kind: 'rect', x: 0, y: y + h / 2, w: W, h, color: c(i) })
y += h
})
return out
}
if (art === 'sunset') {
const horizon = range(r, -0.2, 0.05) * H
out.push({
kind: 'rect',
x: 0,
y: (H / 2 + horizon) / 2,
w: W,
h: H / 2 - horizon,
color: c(4),
})
const sun = range(r, 0.28, 0.4) * m
out.push({ kind: 'circle', x: 0, y: horizon, w: sun, h: sun, color: c(0) })
// the sea covers the sun's lower half; stripes cut across what shows above it
out.push({
kind: 'rect',
x: 0,
y: (horizon - H / 2) / 2,
w: W,
h: H / 2 + horizon,
color: c(2),
})
for (let i = 0; i < 4; i++) {
out.push({
kind: 'rect',
x: 0,
y: horizon + sun * (0.15 + i * 0.18),
w: sun * 2.1,
h: sun * 0.05 * (1 + i * 0.4),
color: c(4),
})
}
for (let i = 0; i < 3; i++) {
out.push({
kind: 'rect',
x: range(r, -0.1, 0.1) * W,
y: horizon - (i + 1) * 0.08 * H,
w: range(r, 0.2, 0.6) * W,
h: 0.008 * H,
color: c(1),
})
}
return out
}
if (art === 'peaks') {
out.push({ kind: 'rect', x: 0, y: 0, w: W, h: H, color: c(4) })
out.push({
kind: 'circle',
x: range(r, -0.3, 0.3) * W,
y: range(r, 0.15, 0.3) * H,
w: 0.1 * m,
h: 0.1 * m,
color: c(1),
})
for (let i = 0; i < 4; i++) {
const w = range(r, 0.45, 0.8) * W
const h = range(r, 0.35, 0.65) * H * (1 - i * 0.12)
// a triangle's centroid sits a third of its height above the base
out.push({
kind: 'tri',
x: range(r, -W / 2 + w / 2, W / 2 - w / 2),
y: -H / 2 + h / 3,
w,
h,
color: c(i % 2 ? 3 : 0),
})
}
return out
}
out.push({ kind: 'rect', x: 0, y: 0, w: W, h: H, color: c(4) })
const n = 4 + Math.floor(r() * 4)
for (let i = 0; i < n; i++) {
const kind = pick<Kind>(r, ['rect', 'circle', 'circle', 'tri'])
const s = range(r, 0.15, 0.4) * m
const w = kind === 'rect' ? s * range(r, 0.6, 2) : s
const h = kind === 'rect' ? s * range(r, 0.6, 2) : s
const hw = kind === 'circle' ? s : Math.min(w, W) / 2
// how far the shape reaches below and above its origin
const below = kind === 'circle' ? s : kind === 'tri' ? h / 3 : h / 2
const above = kind === 'circle' ? s : kind === 'tri' ? (h * 2) / 3 : h / 2
out.push({
kind,
x: range(r, -W / 2 + hw, W / 2 - hw),
y: range(r, -H / 2 + below, H / 2 - above),
w: Math.min(w, W),
h: Math.min(h, H),
color: c(i % 4),
})
}
return out
}
/**
* A print for a wall: seeded flat art (overlapping shapes, color bands, a retro sunset, or
* mountain peaks) on paper, with an optional caption and a slim or wide frame. Its origin is the
* middle of the print with its back to the wall (local -Z). An app can print its own `texture`
* instead; the poster loads nothing itself. Decorative: no collider.
*/
export function Poster({
position = [0, 0, 0],
rotation = [0, 0, 0],
seed = 1,
size = [0.5, 0.7],
art,
frame,
title,
frameColor,
paper = '#f4f1ea',
ink = '#222222',
texture,
}: PosterProps) {
const { unit, palette } = useWorld()
const W = size[0] * unit
const H = size[1] * unit
const m = Math.min(W, H)
const margin = 0.06 * m
const caption = title ? 0.14 * H : 0
const artW = W - margin * 2
const artH = H - margin * 2 - caption
const look = useMemo(() => {
const r = rng(sub(seed, 0))
const rolled = { art: pick(r, ARTS), frame: pick(r, FRAMES) }
const kind = art ?? rolled.art
return { art: kind, frame: frame ?? rolled.frame, shapes: paint(kind, seed, artW, artH) }
}, [seed, art, frame, artW, artH])
const edge = look.frame === 'wide' ? 0.06 * unit : look.frame === 'thin' ? 0.02 * unit : 0
const depth = look.frame === 'none' ? 0.002 * unit : 0.025 * unit
const face = depth + 0.0005 * unit
const artY = caption / 2
const wood = frameColor ?? palette.woodDark
const layer = 0.0003 * unit
return (
<group position={position} rotation={rotation}>
{look.frame !== 'none' && (
<mesh position={[0, 0, depth / 2]} castShadow>
<boxGeometry args={[W + edge * 2, H + edge * 2, depth]} />
<meshStandardMaterial color={wood} roughness={0.6} />
</mesh>
)}
<mesh position={[0, 0, face]}>
<planeGeometry args={[W, H]} />
<meshStandardMaterial color={paper} roughness={0.9} />
</mesh>
<group position={[0, artY, face + layer]}>
{texture ? (
<mesh>
<planeGeometry args={[artW, artH]} />
<meshStandardMaterial map={texture} roughness={0.8} />
</mesh>
) : (
look.shapes.map((s, i) => (
<mesh
key={i}
position={[s.x, s.y, i * layer]}
rotation={s.kind === 'tri' ? [0, 0, Math.PI / 2] : [0, 0, 0]}
scale={s.kind === 'tri' ? [s.h / 1.5, s.w / Math.sqrt(3), 1] : [1, 1, 1]}
>
{s.kind === 'rect' ? (
<planeGeometry args={[s.w, s.h]} />
) : s.kind === 'circle' ? (
<circleGeometry args={[s.w, 32]} />
) : (
<circleGeometry args={[1, 3]} />
)}
<meshStandardMaterial color={s.color} roughness={0.85} />
</mesh>
))
)}
</group>
{title && (
<Sign
position={[0, -H / 2 + margin + caption / 2, face + layer]}
size={(caption * 0.45) / unit}
maxWidth={artW / unit}
color={ink}
variant="body"
>
{title}
</Sign>
)}
</group>
)
}