Begehbare 3D-Visualisierung des Gartens Egglihaldenstrasse 9a
Geometrie massstaeblich aus dem Gartenplan-PDF (1:100) extrahiert: Belaege, Mauern, Pflanzenstandorte und 46 Hoehenkoten. Wohnhaus nach Gartenplan und den Fassadenplaenen von EHLE Architekten. - tools/: Pipeline PDF -> Szenenmodell (MuPDF, Node) - src/: three.js-Szene mit prozeduralen Texturen, Rundgang und Uebersicht - Docker/Traefik fuer garten.aicds.ch, Gitea-Workflow auf main Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Prozedurale Texturen. Die Anwendung soll ohne externe Assets auskommen,
|
||||
* deshalb werden alle Belaege zur Laufzeit auf ein Canvas gezeichnet.
|
||||
*
|
||||
* Konvention: Jede Textur bildet eine quadratische Weltflaeche von `tile`
|
||||
* Metern ab. Die Meshes bekommen weltbezogene UVs (x/tile, z/tile), damit
|
||||
* Plattenraster ueber Flaechengrenzen hinweg durchlaufen - so wie im Plan.
|
||||
*/
|
||||
import {
|
||||
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
|
||||
} from 'three';
|
||||
|
||||
const SIZE = 512;
|
||||
|
||||
/** Deterministischer Zufall, damit jeder Aufbau gleich aussieht. */
|
||||
function mulberry32(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function canvas(): [HTMLCanvasElement, CanvasRenderingContext2D] {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = SIZE;
|
||||
c.height = SIZE;
|
||||
const ctx = c.getContext('2d', { willReadFrequently: true })!;
|
||||
return [c, ctx];
|
||||
}
|
||||
|
||||
/** Kachelbares Wertrauschen, wird fuer Koernung und Farbschwankung genutzt. */
|
||||
function noiseTile(ctx: CanvasRenderingContext2D, seed: number, cells: number, alpha: number, dark = true) {
|
||||
const rnd = mulberry32(seed);
|
||||
const g: number[] = [];
|
||||
for (let i = 0; i < cells * cells; i++) g.push(rnd());
|
||||
const at = (i: number, j: number) => g[((j + cells) % cells) * cells + ((i + cells) % cells)];
|
||||
const img = ctx.getImageData(0, 0, SIZE, SIZE);
|
||||
const step = SIZE / cells;
|
||||
for (let y = 0; y < SIZE; y++) {
|
||||
const fy = y / step, j = Math.floor(fy), ty = fy - j;
|
||||
for (let x = 0; x < SIZE; x++) {
|
||||
const fx = x / step, i = Math.floor(fx), tx = fx - i;
|
||||
const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
|
||||
const v =
|
||||
at(i, j) * (1 - sx) * (1 - sy) + at(i + 1, j) * sx * (1 - sy) +
|
||||
at(i, j + 1) * (1 - sx) * sy + at(i + 1, j + 1) * sx * sy;
|
||||
const k = (y * SIZE + x) * 4;
|
||||
const d = (dark ? -1 : 1) * (v - 0.5) * 2 * alpha * 255;
|
||||
img.data[k] = Math.max(0, Math.min(255, img.data[k] + d));
|
||||
img.data[k + 1] = Math.max(0, Math.min(255, img.data[k + 1] + d));
|
||||
img.data[k + 2] = Math.max(0, Math.min(255, img.data[k + 2] + d));
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
/** Feine Koernung als Ueberlagerung, ohne die Kachelbarkeit zu stoeren. */
|
||||
function grain(ctx: CanvasRenderingContext2D, seed: number, amount: number, dotSize = 1) {
|
||||
const rnd = mulberry32(seed);
|
||||
const n = Math.floor((SIZE * SIZE) / (24 * dotSize));
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = Math.floor(rnd() * 255);
|
||||
ctx.fillStyle = `rgba(${v},${v},${v},${amount})`;
|
||||
ctx.fillRect(rnd() * SIZE, rnd() * SIZE, dotSize, dotSize);
|
||||
}
|
||||
}
|
||||
|
||||
function texture(c: HTMLCanvasElement): CanvasTexture {
|
||||
const t = new CanvasTexture(c);
|
||||
t.wrapS = t.wrapT = RepeatWrapping;
|
||||
t.colorSpace = SRGBColorSpace;
|
||||
t.anisotropy = 8;
|
||||
t.minFilter = LinearMipmapLinearFilter;
|
||||
t.generateMipmaps = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Zeichnet ein Plattenraster mit Fuge. slabW/slabH in Metern. */
|
||||
function slabs(
|
||||
ctx: CanvasRenderingContext2D, tile: number, slabW: number, slabH: number,
|
||||
base: string, jointColor: string, jointM: number, seed: number, variation: number,
|
||||
) {
|
||||
const px = SIZE / tile;
|
||||
ctx.fillStyle = jointColor;
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
const rnd = mulberry32(seed);
|
||||
const cols = Math.round(tile / slabW);
|
||||
const rows = Math.round(tile / slabH);
|
||||
const j = Math.max(1, jointM * px);
|
||||
const [r, g, b] = base.match(/\d+/g)!.map(Number);
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const d = (rnd() - 0.5) * 2 * variation;
|
||||
ctx.fillStyle = `rgb(${Math.round(r + d)},${Math.round(g + d)},${Math.round(b + d)})`;
|
||||
ctx.fillRect(col * slabW * px + j / 2, row * slabH * px + j / 2, slabW * px - j, slabH * px - j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Belag {
|
||||
map: CanvasTexture;
|
||||
tile: number;
|
||||
roughness: number;
|
||||
color?: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, Belag>();
|
||||
|
||||
function make(key: string, tile: number, roughness: number, draw: (ctx: CanvasRenderingContext2D) => void): Belag {
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
const [c, ctx] = canvas();
|
||||
draw(ctx);
|
||||
const belag: Belag = { map: texture(c), tile, roughness };
|
||||
cache.set(key, belag);
|
||||
return belag;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Belaege
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Travertinplatten 80 x 80 x 3 cm, warmes Creme mit feiner Poren-Struktur. */
|
||||
export const travertin80 = () => make('travertin80', 3.2, 0.72, (ctx) => {
|
||||
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(209,195,168)', 'rgb(170,157,134)', 0.008, 11, 7);
|
||||
noiseTile(ctx, 21, 32, 0.05);
|
||||
grain(ctx, 31, 0.05, 2);
|
||||
});
|
||||
|
||||
/** Travertinplatten 40 x 60 x 3 cm, im Plan mit Dekorkies in den Fugen. */
|
||||
export const travertin4060 = () => make('travertin4060', 2.4, 0.75, (ctx) => {
|
||||
slabs(ctx, 2.4, 0.4, 0.6, 'rgb(212,199,172)', 'rgb(158,150,133)', 0.02, 12, 8);
|
||||
noiseTile(ctx, 22, 32, 0.05);
|
||||
grain(ctx, 32, 0.06, 2);
|
||||
});
|
||||
|
||||
/** Travertinstellplatten als schmaler Randstreifen. */
|
||||
export const travertinstell = () => make('travertinstell', 2.4, 0.75, (ctx) => {
|
||||
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(206,192,166)', 'rgb(164,154,135)', 0.012, 13, 8);
|
||||
noiseTile(ctx, 23, 32, 0.05);
|
||||
grain(ctx, 33, 0.05, 2);
|
||||
});
|
||||
|
||||
/** Keramikplatten 60 x 60 x 2 cm, grau - bauseits vorhanden (siehe Fotos). */
|
||||
export const keramik = () => make('keramik', 2.4, 0.55, (ctx) => {
|
||||
slabs(ctx, 2.4, 0.6, 0.6, 'rgb(160,158,154)', 'rgb(128,127,124)', 0.006, 14, 5);
|
||||
noiseTile(ctx, 24, 48, 0.035);
|
||||
grain(ctx, 34, 0.04, 1);
|
||||
});
|
||||
|
||||
/** Tegula-Pflastersteine grau, kleinformatig und leicht unregelmaessig. */
|
||||
export const tegula = () => make('tegula', 1.2, 0.85, (ctx) => {
|
||||
const px = SIZE / 1.2;
|
||||
ctx.fillStyle = 'rgb(118,116,114)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
const rnd = mulberry32(15);
|
||||
const sw = 0.15, sh = 0.1;
|
||||
const cols = Math.round(1.2 / sw), rows = Math.round(1.2 / sh);
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const off = (row % 2) * 0.5;
|
||||
for (let col = -1; col <= cols; col++) {
|
||||
const v = 140 + rnd() * 34;
|
||||
ctx.fillStyle = `rgb(${v | 0},${(v - 2) | 0},${(v - 5) | 0})`;
|
||||
const x = (col + off) * sw * px + 1.2;
|
||||
const y = row * sh * px + 1.2;
|
||||
ctx.fillRect(x, y, sw * px - 2.4, sh * px - 2.4);
|
||||
}
|
||||
}
|
||||
noiseTile(ctx, 25, 24, 0.06);
|
||||
grain(ctx, 35, 0.09, 1);
|
||||
});
|
||||
|
||||
/** Verbundsteine des Nachbarn, etwas dunkler als Tegula. */
|
||||
export const verbundstein = () => make('verbundstein', 1.2, 0.88, (ctx) => {
|
||||
const px = SIZE / 1.2;
|
||||
ctx.fillStyle = 'rgb(110,108,106)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
const rnd = mulberry32(16);
|
||||
for (let row = 0; row < 8; row++) {
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const v = 126 + rnd() * 30;
|
||||
ctx.fillStyle = `rgb(${v | 0},${v | 0},${(v - 4) | 0})`;
|
||||
ctx.fillRect(col * 0.15 * px + 1.5, row * 0.15 * px + 1.5, 0.15 * px - 3, 0.15 * px - 3);
|
||||
}
|
||||
}
|
||||
grain(ctx, 36, 0.08, 1);
|
||||
});
|
||||
|
||||
/** Rasen: gemaehte Flaeche mit leichten Bahnen. */
|
||||
export const rasen = () => make('rasen', 2.0, 0.95, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(96,133,62)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
noiseTile(ctx, 41, 16, 0.09);
|
||||
noiseTile(ctx, 42, 64, 0.07);
|
||||
const rnd = mulberry32(43);
|
||||
for (let i = 0; i < 5200; i++) {
|
||||
const x = rnd() * SIZE, y = rnd() * SIZE;
|
||||
const v = rnd();
|
||||
ctx.strokeStyle = `rgba(${(78 + v * 62) | 0},${(118 + v * 66) | 0},${(44 + v * 40) | 0},0.55)`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + (rnd() - 0.5) * 3, y - 2 - rnd() * 4);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
/** Schotterwiese: magerer Kiesrasen, heller und offener als der Rasen. */
|
||||
export const schotterwiese = () => make('schotterwiese', 2.0, 0.98, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(139,146,104)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
noiseTile(ctx, 44, 20, 0.1);
|
||||
const rnd = mulberry32(45);
|
||||
for (let i = 0; i < 2600; i++) {
|
||||
const v = 150 + rnd() * 70;
|
||||
ctx.fillStyle = `rgba(${v | 0},${(v - 4) | 0},${(v - 18) | 0},0.8)`;
|
||||
const r = 1 + rnd() * 2.4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(rnd() * SIZE, rnd() * SIZE, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
for (let i = 0; i < 1800; i++) {
|
||||
const x = rnd() * SIZE, y = rnd() * SIZE;
|
||||
ctx.strokeStyle = `rgba(112,138,64,0.5)`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + (rnd() - 0.5) * 3, y - 3 - rnd() * 3);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
/** Dekorkies in den Pflanzflaechen und um den Flammengrill. */
|
||||
export const dekorkies = () => make('dekorkies', 1.0, 0.99, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(150,144,132)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
const rnd = mulberry32(46);
|
||||
for (let i = 0; i < 6500; i++) {
|
||||
const v = 128 + rnd() * 90;
|
||||
ctx.fillStyle = `rgb(${v | 0},${(v - 5) | 0},${(v - 16) | 0})`;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 4, 2 + rnd() * 3, rnd() * 3.14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
noiseTile(ctx, 47, 24, 0.07);
|
||||
});
|
||||
|
||||
/** Pflanzbeet: dunkler Humus mit Rindenanteil. */
|
||||
export const beet = () => make('beet', 1.5, 0.99, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(74,58,44)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
noiseTile(ctx, 48, 20, 0.12);
|
||||
const rnd = mulberry32(49);
|
||||
for (let i = 0; i < 2200; i++) {
|
||||
const v = 60 + rnd() * 60;
|
||||
ctx.fillStyle = `rgb(${v | 0},${(v - 12) | 0},${(v - 24) | 0})`;
|
||||
ctx.save();
|
||||
ctx.translate(rnd() * SIZE, rnd() * SIZE);
|
||||
ctx.rotate(rnd() * 3.14);
|
||||
ctx.fillRect(0, 0, 3 + rnd() * 9, 2 + rnd() * 3);
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
|
||||
/** Spielsand. */
|
||||
export const spielsand = () => make('spielsand', 1.5, 1.0, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(211,192,155)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
noiseTile(ctx, 50, 28, 0.05);
|
||||
grain(ctx, 51, 0.16, 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bauteile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Travertin-Mauerwerk in Schichten, wie auf den Referenzfotos des Plans. */
|
||||
export const travertinMauer = () => make('travertinMauer', 1.6, 0.7, (ctx) => {
|
||||
const px = SIZE / 1.6;
|
||||
ctx.fillStyle = 'rgb(168,152,126)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
const rnd = mulberry32(61);
|
||||
const h = 0.2;
|
||||
for (let row = 0; row < Math.round(1.6 / h); row++) {
|
||||
let x = -((row % 2) * 0.3);
|
||||
while (x < 1.6) {
|
||||
const w = 0.4 + rnd() * 0.4;
|
||||
const v = 202 + rnd() * 26;
|
||||
ctx.fillStyle = `rgb(${v | 0},${(v - 17) | 0},${(v - 44) | 0})`;
|
||||
ctx.fillRect(x * px + 1, row * h * px + 1, w * px - 2, h * px - 2);
|
||||
x += w;
|
||||
}
|
||||
}
|
||||
noiseTile(ctx, 62, 40, 0.045);
|
||||
grain(ctx, 63, 0.05, 2);
|
||||
});
|
||||
|
||||
/** Sichtbeton der Winkelelemente und der Nachbarmauer. */
|
||||
export const beton = () => make('beton', 2.0, 0.8, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(176,174,169)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
noiseTile(ctx, 64, 12, 0.05);
|
||||
noiseTile(ctx, 65, 48, 0.025);
|
||||
grain(ctx, 66, 0.04, 1);
|
||||
// Schalungsstoesse
|
||||
ctx.strokeStyle = 'rgba(120,118,114,0.35)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, SIZE / 2); ctx.lineTo(SIZE, SIZE / 2);
|
||||
ctx.moveTo(SIZE / 2, 0); ctx.lineTo(SIZE / 2, SIZE);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = 'rgba(120,118,114,0.4)';
|
||||
for (const [x, y] of [[SIZE * 0.25, SIZE * 0.25], [SIZE * 0.75, SIZE * 0.25], [SIZE * 0.25, SIZE * 0.75], [SIZE * 0.75, SIZE * 0.75]]) {
|
||||
ctx.beginPath(); ctx.arc(x, y, 3.5, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
/** Gestapelte Holzscheiter der Scheiterwand hinter dem Flammengrill. */
|
||||
export const holzscheiter = () => make('holzscheiter', 1.0, 0.9, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(48,38,30)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
const rnd = mulberry32(67);
|
||||
for (let i = 0; i < 260; i++) {
|
||||
const r = 12 + rnd() * 16;
|
||||
const x = rnd() * SIZE, y = rnd() * SIZE;
|
||||
const v = 128 + rnd() * 74;
|
||||
ctx.fillStyle = `rgb(${v | 0},${(v - 26) | 0},${(v - 56) | 0})`;
|
||||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(60,44,32,0.75)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(90,66,46,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let k = 1; k <= 2; k++) {
|
||||
ctx.beginPath(); ctx.arc(x, y, (r * k) / 3, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Vertikale Holzschalung der Fassade - graubraun vergraut, wie auf den Fotos. */
|
||||
export const fassadeHolz = () => make('fassadeHolz', 1.2, 0.78, (ctx) => {
|
||||
const px = SIZE / 1.2;
|
||||
ctx.fillStyle = 'rgb(124,114,100)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
const rnd = mulberry32(71);
|
||||
const w = 0.06;
|
||||
for (let col = 0; col < Math.round(1.2 / w); col++) {
|
||||
const v = 152 + rnd() * 32;
|
||||
ctx.fillStyle = `rgb(${v | 0},${(v - 8) | 0},${(v - 20) | 0})`;
|
||||
ctx.fillRect(col * w * px + 1.5, 0, w * px - 3, SIZE);
|
||||
ctx.strokeStyle = 'rgba(84,76,66,0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const y = rnd() * SIZE;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(col * w * px + 2, y);
|
||||
ctx.lineTo(col * w * px + w * px - 3, y + (rnd() - 0.5) * 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
noiseTile(ctx, 72, 32, 0.03);
|
||||
});
|
||||
|
||||
/** Heller Betonsockel unter der Holzschalung. */
|
||||
export const sockel = () => make('sockel', 2.0, 0.72, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(214,211,204)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
noiseTile(ctx, 73, 16, 0.035);
|
||||
grain(ctx, 74, 0.03, 1);
|
||||
});
|
||||
|
||||
/** Extensive Dachbegruenung der Flachdaecher. */
|
||||
export const gruendach = () => make('gruendach', 2.0, 1.0, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(108,116,72)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
noiseTile(ctx, 75, 18, 0.13);
|
||||
const rnd = mulberry32(76);
|
||||
for (let i = 0; i < 2800; i++) {
|
||||
const v = rnd();
|
||||
ctx.fillStyle = `rgba(${(96 + v * 80) | 0},${(112 + v * 60) | 0},${(58 + v * 40) | 0},0.75)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(rnd() * SIZE, rnd() * SIZE, 1.5 + rnd() * 3.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
/** Dunkler Spritzschutz-Kiesstreifen entlang der Fassade (siehe Fotos). */
|
||||
export const spritzschutz = () => make('spritzschutz', 1.0, 0.98, (ctx) => {
|
||||
ctx.fillStyle = 'rgb(62,62,66)';
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
const rnd = mulberry32(77);
|
||||
for (let i = 0; i < 4200; i++) {
|
||||
const v = 52 + rnd() * 78;
|
||||
ctx.fillStyle = `rgb(${v | 0},${v | 0},${(v + 6) | 0})`;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 4, 2 + rnd() * 3, rnd() * 3.14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user