Skip to content

Commit 4531b8d

Browse files
authored
feat: support for 'image' shape in spawner (#16)
1 parent 347f59f commit 4531b8d

14 files changed

Lines changed: 621 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ A high-performance particle physics simulation system with interactive playgroun
1111
- **Playground Sessions**: Save/load/share sessions (module settings, oscillators, and optional particles/joints)
1212
- **Real-time Oscillators**: Animate any module parameter with configurable frequency and bounds
1313
- **Interactive Playground**: React-based interface with undo/redo, hotkeys, and live parameter adjustment
14-
- **Text Spawner**: Spawn particles from text in the core spawner and INIT UI
14+
- **Text/Image Spawner**: Spawn particles from text or images in the core spawner and INIT UI
1515

1616
## Documentation
1717

docs/playground-user-guide.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ The INIT panel controls how particles are spawned when using the Restart button
158158
- **Donut**: Particles arranged in a ring (inner + outer radius)
159159
- **Square**: Particles arranged in a square
160160
- **Text**: Particles spawn to form the typed text
161+
- **Image**: Particles spawn to form an image from a URL or upload
161162

162163
### Particle Properties
163164

@@ -182,6 +183,7 @@ The INIT panel controls how particles are spawned when using the Restart button
182183
- **Square size**: Square Size slider (Square)
183184
- **Square corner radius**: Corner Radius slider (Square)
184185
- **Text fields**: Text, Text Size, and Font (Sans Serif / Serif / Monospace)
186+
- **Image fields**: Image URL or upload (URL disabled when upload is used), plus Image Size (max dimension). Transparent pixels are ignored and particle colors come from the image.
185187

186188
## Physics Modules
187189

docs/user-guide.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ Notes
158158

159159
### Spawner utility
160160

161-
The `Spawner` helper generates `IParticle[]` for common shapes, including text.
161+
The `Spawner` helper generates `IParticle[]` for common shapes, including text and images.
162162

163163
```ts
164164
import { Spawner } from "@cazala/party";
@@ -187,6 +187,28 @@ Notes:
187187
- `position` + `align` define the anchor point for the text bounds.
188188
- Supported fonts in the playground UI: `sans-serif`, `serif`, `monospace`.
189189

190+
Image example:
191+
192+
```ts
193+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
194+
const particles = spawner.initParticles({
195+
count: 12000,
196+
shape: "image",
197+
center: { x: 0, y: 0 },
198+
position: { x: 0, y: 0 },
199+
align: { horizontal: "center", vertical: "center" },
200+
imageData,
201+
imageSize: 400, // scales to this max dimension
202+
size: 3,
203+
mass: 1,
204+
});
205+
```
206+
207+
Notes:
208+
209+
- `imageData` is required and must be provided synchronously.
210+
- Fully transparent pixels are ignored; particle colors come from the image pixels.
211+
190212
#### Engine methods and lifecycles
191213

192214
- `initialize()`

packages/core/README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ A high-performance TypeScript particle physics engine with dual runtime support
1212
- **Advanced Rendering**: Trails, particle instancing, line rendering with multiple color modes
1313
- **Export/Import Presets**: Export/import module settings (inputs + enabled state)
1414
- **Cross-platform**: Works in all modern browsers with automatic feature detection
15-
- **Spawner Utility**: Generate particle shapes, including text
15+
- **Spawner Utility**: Generate particle shapes, including text and images
1616

1717
## Installation
1818

@@ -192,7 +192,7 @@ engine.unpinAll();
192192

193193
### Spawner
194194

195-
Generate particle arrays from common shapes (including text) using `Spawner`:
195+
Generate particle arrays from common shapes (including text and images) using `Spawner`:
196196

197197
```typescript
198198
import { Spawner } from "@cazala/party";
@@ -220,6 +220,28 @@ Notes:
220220
- `size` controls particle radius; `textSize` is the font size used to rasterize text.
221221
- Playground font options: `sans-serif`, `serif`, `monospace`.
222222

223+
Image example:
224+
225+
```typescript
226+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
227+
const imageParticles = spawner.initParticles({
228+
count: 12000,
229+
shape: "image",
230+
center: { x: 0, y: 0 },
231+
position: { x: 0, y: 0 },
232+
align: { horizontal: "center", vertical: "center" },
233+
imageData,
234+
imageSize: 400, // scales to this max dimension
235+
size: 3,
236+
mass: 1,
237+
});
238+
```
239+
240+
Notes:
241+
242+
- `imageData` must be provided synchronously (no URL fetching inside the spawner).
243+
- Fully transparent pixels are skipped; particle colors come from image pixels.
244+
223245
### Modules
224246

225247
Modules are pluggable components that contribute to simulation or rendering:

packages/core/src/spawner.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ export type SpawnShape =
2828
| "circle"
2929
| "donut"
3030
| "square"
31-
| "text";
31+
| "text"
32+
| "image";
3233

3334
export type TextHorizontalAlign = "left" | "center" | "right";
3435
export type TextVerticalAlign = "top" | "center" | "bottom";
@@ -56,6 +57,9 @@ export interface SpawnOptions {
5657
textSize?: number;
5758
position?: { x: number; y: number };
5859
align?: { horizontal: TextHorizontalAlign; vertical: TextVerticalAlign };
60+
// image
61+
imageData?: ImageData | null;
62+
imageSize?: number;
5963
}
6064

6165
function calculateVelocity(
@@ -125,6 +129,8 @@ export class Spawner {
125129
textSize = 64,
126130
position,
127131
align,
132+
imageData,
133+
imageSize,
128134
} = options;
129135

130136
const particles: IParticle[] = [];
@@ -258,6 +264,119 @@ export class Spawner {
258264
return particles;
259265
}
260266

267+
if (shape === "image") {
268+
if (!imageData) return particles;
269+
const width = Math.floor(imageData.width);
270+
const height = Math.floor(imageData.height);
271+
if (
272+
!Number.isFinite(width) ||
273+
!Number.isFinite(height) ||
274+
width <= 0 ||
275+
height <= 0
276+
)
277+
return particles;
278+
279+
const data = imageData.data;
280+
const sampleStepTarget = Math.max(1, Math.round(size));
281+
const targetSize =
282+
typeof imageSize === "number" && Number.isFinite(imageSize) && imageSize > 0
283+
? imageSize
284+
: Math.max(width, height);
285+
const scale = Math.max(0.0001, targetSize / Math.max(width, height));
286+
const sampleStepSource = Math.max(1, Math.round(sampleStepTarget / scale));
287+
const points: {
288+
x: number;
289+
y: number;
290+
color: { r: number; g: number; b: number; a: number };
291+
}[] = [];
292+
293+
for (let y = 0; y < height; y += sampleStepSource) {
294+
for (let x = 0; x < width; x += sampleStepSource) {
295+
const idx = (y * width + x) * 4;
296+
const alpha = data[idx + 3];
297+
if (alpha === 0) continue;
298+
points.push({
299+
x: x * scale,
300+
y: y * scale,
301+
color: {
302+
r: data[idx] / 255,
303+
g: data[idx + 1] / 255,
304+
b: data[idx + 2] / 255,
305+
a: alpha / 255,
306+
},
307+
});
308+
}
309+
}
310+
311+
const maxCount = Math.max(0, Math.floor(count));
312+
if (maxCount <= 0 || points.length === 0) return particles;
313+
314+
const imagePosition = position ?? center;
315+
const horizontal = align?.horizontal ?? "center";
316+
const vertical = align?.vertical ?? "center";
317+
const scaledWidth = width * scale;
318+
const scaledHeight = height * scale;
319+
const originX =
320+
horizontal === "left"
321+
? imagePosition.x
322+
: horizontal === "right"
323+
? imagePosition.x - scaledWidth
324+
: imagePosition.x - scaledWidth / 2;
325+
const originY =
326+
vertical === "top"
327+
? imagePosition.y
328+
: vertical === "bottom"
329+
? imagePosition.y - scaledHeight
330+
: imagePosition.y - scaledHeight / 2;
331+
332+
const baseCount = Math.min(maxCount, points.length);
333+
const stride = points.length / baseCount;
334+
for (let i = 0; i < baseCount; i++) {
335+
const idx = Math.floor(i * stride);
336+
const point = points[idx];
337+
if (!point) continue;
338+
const x = originX + point.x;
339+
const y = originY + point.y;
340+
const { vx, vy } = calculateVelocity(
341+
{ x, y },
342+
imagePosition,
343+
velocity
344+
);
345+
particles.push({
346+
position: { x, y },
347+
velocity: { x: vx, y: vy },
348+
size,
349+
mass,
350+
color: point.color,
351+
});
352+
}
353+
354+
const extraCount = maxCount - baseCount;
355+
if (extraCount > 0) {
356+
const jitter = sampleStepTarget * 0.4;
357+
for (let i = 0; i < extraCount; i++) {
358+
const point = points[Math.floor(Math.random() * points.length)];
359+
if (!point) continue;
360+
const x = originX + point.x + (Math.random() - 0.5) * jitter;
361+
const y = originY + point.y + (Math.random() - 0.5) * jitter;
362+
const { vx, vy } = calculateVelocity(
363+
{ x, y },
364+
imagePosition,
365+
velocity
366+
);
367+
particles.push({
368+
position: { x, y },
369+
velocity: { x: vx, y: vy },
370+
size,
371+
mass,
372+
color: point.color,
373+
});
374+
}
375+
}
376+
377+
return particles;
378+
}
379+
261380
if (shape === "grid") {
262381
const cols = Math.ceil(Math.sqrt(count));
263382
const rows = Math.ceil(count / cols);

packages/playground/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Interactive web application for experimenting with particle physics simulations.
1212
- **Sophisticated Undo/Redo**: Command pattern with transaction support and action grouping
1313
- **Parameter Oscillators**: Animate any module parameter with configurable frequency and bounds
1414
- **Comprehensive Hotkeys**: Efficient keyboard-driven workflow
15-
- **Text Shape Spawn**: Type text to spawn particles in the INIT panel
15+
- **Text/Image Shape Spawn**: Type text or provide an image to spawn particles in the INIT panel
1616

1717
### Physics Modules
1818
- **Environment**: Gravity, inertia, friction, damping with directional/radial options

packages/playground/src/components/InitControls.tsx

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useAppDispatch } from "../hooks/useAppDispatch";
1616
import { lockSpawnTemporarily } from "../slices/init";
1717
import { buildPlayPath } from "../utils/playUrl";
1818
import { consumeSharedSessionFromUrlOnce } from "../utils/urlSharedSession";
19+
import { InitImageInput } from "./InitImageInput";
1920
import "./InitControls.css";
2021

2122
export function InitControls() {
@@ -45,6 +46,10 @@ export function InitControls() {
4546
text,
4647
textFont,
4748
textSize,
49+
imageData,
50+
imageSize,
51+
imageSource,
52+
imageUrl,
4853
hasInitialSpawned,
4954
isSpawnLocked,
5055
setNumParticles,
@@ -62,6 +67,10 @@ export function InitControls() {
6267
setText,
6368
setTextFont,
6469
setTextSize,
70+
setImageData,
71+
setImageSize,
72+
setImageSource,
73+
setImageUrl,
6574
markInitialSpawned,
6675
initState,
6776
} = useInit();
@@ -269,6 +278,8 @@ export function InitControls() {
269278
text,
270279
textFont,
271280
textSize,
281+
imageData,
282+
imageSize,
272283
});
273284
}, [
274285
// NOTE: spawnParticles is intentionally NOT in this dependency array
@@ -291,6 +302,8 @@ export function InitControls() {
291302
text,
292303
textFont,
293304
textSize,
305+
imageData,
306+
imageSize,
294307
barsVisible, // Include to detect when component remounts due to UI changes
295308
]);
296309

@@ -316,6 +329,8 @@ export function InitControls() {
316329
text,
317330
textFont,
318331
textSize,
332+
imageData,
333+
imageSize,
319334
]);
320335

321336
// Create grid joints after particles are spawned
@@ -380,7 +395,14 @@ export function InitControls() {
380395
const handleSpawnChange = (
381396
options: {
382397
newNumParticles?: number;
383-
newShape?: "grid" | "random" | "circle" | "donut" | "square" | "text";
398+
newShape?:
399+
| "grid"
400+
| "random"
401+
| "circle"
402+
| "donut"
403+
| "square"
404+
| "text"
405+
| "image";
384406
newSpacing?: number;
385407
newParticleSize?: number;
386408
newRadius?: number;
@@ -462,7 +484,8 @@ export function InitControls() {
462484
| "circle"
463485
| "donut"
464486
| "square"
465-
| "text",
487+
| "text"
488+
| "image",
466489
})
467490
}
468491
options={[
@@ -471,6 +494,7 @@ export function InitControls() {
471494
{ value: "donut", label: "Donut" },
472495
{ value: "square", label: "Square" },
473496
{ value: "text", label: "Text" },
497+
{ value: "image", label: "Image" },
474498
{ value: "random", label: "Random" },
475499
]}
476500
/>
@@ -575,6 +599,26 @@ export function InitControls() {
575599
/>
576600
</>
577601
)}
602+
{shape === "image" && (
603+
<>
604+
<InitImageInput
605+
imageUrl={imageUrl}
606+
imageSource={imageSource}
607+
imageData={imageData}
608+
onImageUrlChange={setImageUrl}
609+
onImageSourceChange={setImageSource}
610+
onImageDataChange={setImageData}
611+
/>
612+
<Slider
613+
label="Image Size"
614+
value={imageSize}
615+
min={20}
616+
max={2048}
617+
step={1}
618+
onChange={(value) => setImageSize(value)}
619+
/>
620+
</>
621+
)}
578622
<MultiColorPicker colors={colors} onColorsChange={handleColorsChange} />
579623
<Slider
580624
label="Velocity Speed"

0 commit comments

Comments
 (0)