diff --git a/fedac/native/pieces/painting.mjs b/fedac/native/pieces/painting.mjs new file mode 100644 index 000000000..318e182a0 --- /dev/null +++ b/fedac/native/pieces/painting.mjs @@ -0,0 +1,160 @@ +// painting.mjs — Nopaint freehand drawing for AC Native +// Persistent canvas with line brush. Touch to draw, lift to bake. +// Commands: "no" clears, scroll adjusts thickness. + +let T = __theme.update(); + +// Canvas state +let canvas = null; // persistent painting buffer +let buffer = null; // temporary stroke overlay +let sw = 0, sh = 0; + +// Brush state +let thickness = 2; +let color = [255, 255, 255]; // default white (will adapt to theme) +let painting = false; +let points = []; +let frame = 0; +let cursorX = 0, cursorY = 0; // Track cursor for preview +let mkPaintingFn = null; // Stored for "no" command + +// Color palette (cycle with middle-click or 'c' key) +const palette = [ + [255, 255, 255], [255, 0, 0], [255, 165, 0], [255, 255, 0], + [0, 128, 0], [0, 255, 255], [0, 0, 255], [128, 0, 128], + [128, 128, 128], [0, 0, 0], +]; +let colorIdx = 0; + +function boot({ screen, wipe, painting: mkPainting }) { + sw = screen.width; + sh = screen.height; + mkPaintingFn = mkPainting; + + // Create persistent canvas and stroke buffer + canvas = mkPainting(sw, sh, (p) => { + T = __theme.update(); + p.wipe(T.dark ? 20 : 240, T.dark ? 20 : 238, T.dark ? 25 : 232); + }); + buffer = mkPainting(sw, sh, (p) => { p.wipe(0, 0, 0, 0); }); + + color = T.dark ? [255, 255, 255] : [0, 0, 0]; + cursorX = sw / 2; + cursorY = sh / 2; + wipe(T.bg[0], T.bg[1], T.bg[2]); +} + +function act({ event: e, screen, sound, system, page }) { + // Track cursor position from any mouse/touch event + if (e.x !== undefined) { cursorX = e.x; cursorY = e.y; } + + // Start stroke + if (e.is("touch")) { + painting = true; + points = [{ x: e.x, y: e.y }]; + } + + // Continue stroke + if (e.is("draw") && painting) { + const last = points[points.length - 1]; + if (!last || last.x !== e.x || last.y !== e.y) { + points.push({ x: e.x, y: e.y }); + } + } + + // End stroke — bake will happen in paint + if (e.is("lift") && painting) { + painting = false; + } + + // Scroll: adjust thickness + if (e.is("scroll")) { + const delta = e.y > 0 ? -1 : e.y < 0 ? 1 : 0; + thickness = Math.max(1, Math.min(thickness + delta, 50)); + } + + // 'c' key: cycle color + if (e.is("keyboard:down:c")) { + colorIdx = (colorIdx + 1) % palette.length; + color = palette[colorIdx]; + } + + // 'n' key: clear canvas (the "no" command) + if (e.is("keyboard:down:n") && canvas && mkPaintingFn) { + canvas = mkPaintingFn(sw, sh, (p) => { + T = __theme.update(); + p.wipe(T.dark ? 20 : 240, T.dark ? 20 : 238, T.dark ? 25 : 232); + }); + } + + // Escape: jump to prompt + if (e.is("keyboard:down:escape")) { + system?.jump?.("prompt"); + } +} + +function paint({ wipe, ink, line, circle, paste, page, screen, write }) { + frame++; + T = __theme.update(); + + // 1. Draw the persistent canvas to screen + wipe(T.bg[0], T.bg[1], T.bg[2]); + if (canvas) paste(canvas); + + // 2. If actively painting, draw current stroke into buffer and overlay it + if (painting && points.length > 1) { + // Clear buffer + page(buffer).wipe(0, 0, 0, 0); + + // Draw stroke into buffer + for (let i = 0; i < points.length - 1; i++) { + const a = points[i], b = points[i + 1]; + if (thickness === 1) { + ink(color[0], color[1], color[2]).line(a.x, a.y, b.x, b.y); + } else { + ink(color[0], color[1], color[2]).line(a.x, a.y, b.x, b.y, thickness); + } + } + + // Switch back to screen + page(screen); + + // Overlay buffer onto screen + paste(buffer); + } + + // 3. If stroke just ended, bake buffer onto canvas + if (!painting && points.length > 1) { + // Draw stroke directly onto canvas + page(canvas); + for (let i = 0; i < points.length - 1; i++) { + const a = points[i], b = points[i + 1]; + if (thickness === 1) { + ink(color[0], color[1], color[2]).line(a.x, a.y, b.x, b.y); + } else { + ink(color[0], color[1], color[2]).line(a.x, a.y, b.x, b.y, thickness); + } + } + page(screen); + points = []; + + // Redraw canvas to screen + paste(canvas); + } + + // 4. Brush preview cursor (when not painting) + if (!painting && thickness > 2) { + ink(color[0], color[1], color[2], 80).circle(cursorX, cursorY, Math.floor(thickness / 2), false); + } + + // 5. HUD + const fg = T.fg; + ink(fg, fg, fg, 180).write(`${thickness}px`, { x: 4, y: sh - 12, size: 1, font: "font_1" }); + ink(color[0], color[1], color[2]).box(30, sh - 12, 8, 8, true); + ink(fg, fg, fg, 120).write("c:color n:clear esc:exit", { x: 44, y: sh - 12, size: 1, font: "font_1" }); +} + +function sim() {} +function leave() {} + +export { boot, act, paint, sim, leave }; diff --git a/fedac/native/src/graph.c b/fedac/native/src/graph.c index 2f3f2f581..1fb19c50f 100644 --- a/fedac/native/src/graph.c +++ b/fedac/native/src/graph.c @@ -59,6 +59,24 @@ void graph_line(ACGraph *g, int x0, int y0, int x1, int y1) { } } +// Thick line: draw filled circles along the Bresenham path +void graph_line_thick(ACGraph *g, int x0, int y0, int x1, int y1, int thickness) { + if (thickness <= 1) { graph_line(g, x0, y0, x1, y1); return; } + int r = (thickness - 1) / 2; + int dx = abs(x1 - x0); + int dy = -abs(y1 - y0); + int sx = x0 < x1 ? 1 : -1; + int sy = y0 < y1 ? 1 : -1; + int err = dx + dy; + for (;;) { + graph_circle(g, x0, y0, r, 1); + if (x0 == x1 && y0 == y1) break; + int e2 = 2 * err; + if (e2 >= dy) { err += dy; x0 += sx; } + if (e2 <= dx) { err += dx; y0 += sy; } + } +} + void graph_box(ACGraph *g, int x, int y, int w, int h, int filled) { if (filled) { // Clip to framebuffer bounds once diff --git a/fedac/native/src/graph.h b/fedac/native/src/graph.h index 0f90502f9..20f423103 100644 --- a/fedac/native/src/graph.h +++ b/fedac/native/src/graph.h @@ -21,6 +21,7 @@ void graph_wipe(ACGraph *g, ACColor color); void graph_ink(ACGraph *g, ACColor color); void graph_plot(ACGraph *g, int x, int y); void graph_line(ACGraph *g, int x0, int y0, int x1, int y1); +void graph_line_thick(ACGraph *g, int x0, int y0, int x1, int y1, int thickness); void graph_box(ACGraph *g, int x, int y, int w, int h, int filled); void graph_circle(ACGraph *g, int cx, int cy, int r, int filled); diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c index bd59a8c75..8b5b305fb 100644 --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -536,7 +536,13 @@ static JSValue js_line(JSContext *ctx, JSValueConst this_val, int argc, JSValueC JS_ToInt32(ctx, &y0, argv[1]); JS_ToInt32(ctx, &x1, argv[2]); JS_ToInt32(ctx, &y1, argv[3]); - graph_line(current_rt->graph, x0, y0, x1, y1); + if (argc >= 5) { + int thickness; + JS_ToInt32(ctx, &thickness, argv[4]); + graph_line_thick(current_rt->graph, x0, y0, x1, y1, thickness); + } else { + graph_line(current_rt->graph, x0, y0, x1, y1); + } return JS_UNDEFINED; } @@ -1798,7 +1804,7 @@ ACRuntime *js_init(ACGraph *graph, ACInput *input, ACAudio *audio, ACWifi *wifi, // Register top-level graphics functions JS_SetPropertyStr(ctx, global, "wipe", JS_NewCFunction(ctx, js_wipe, "wipe", 3)); JS_SetPropertyStr(ctx, global, "ink", JS_NewCFunction(ctx, js_ink, "ink", 4)); - JS_SetPropertyStr(ctx, global, "line", JS_NewCFunction(ctx, js_line, "line", 4)); + JS_SetPropertyStr(ctx, global, "line", JS_NewCFunction(ctx, js_line, "line", 5)); JS_SetPropertyStr(ctx, global, "box", JS_NewCFunction(ctx, js_box, "box", 5)); JS_SetPropertyStr(ctx, global, "circle", JS_NewCFunction(ctx, js_circle, "circle", 4)); JS_SetPropertyStr(ctx, global, "qr", JS_NewCFunction(ctx, js_qr, "qr", 4)); @@ -1915,6 +1921,23 @@ static JSValue js_painting(JSContext *ctx, JSValueConst this_val, int argc, JSVa JS_SetPropertyStr(ctx, painting, "width", JS_NewInt32(ctx, w)); JS_SetPropertyStr(ctx, painting, "height", JS_NewInt32(ctx, h)); + // Expose pixels as a Uint8Array view into the framebuffer memory. + // Nopaint uses buffer.pixels[i] to adjust alpha during bake. + // Note: pixel format is ARGB32 (native byte order). + { + int byte_len = tex_fb->stride * h * 4; + JSValue ab = JS_NewArrayBuffer(ctx, (uint8_t *)tex_fb->pixels, byte_len, + NULL, NULL, 0); // No free — painting finalizer handles the framebuffer + // Construct Uint8Array from the ArrayBuffer via JS eval + JSValue global = JS_GetGlobalObject(ctx); + JSValue uint8_ctor = JS_GetPropertyStr(ctx, global, "Uint8Array"); + JSValue pixels = JS_CallConstructor(ctx, uint8_ctor, 1, &ab); + JS_SetPropertyStr(ctx, painting, "pixels", pixels); + JS_FreeValue(ctx, uint8_ctor); + JS_FreeValue(ctx, global); + JS_FreeValue(ctx, ab); + } + // If callback provided, render into the off-screen buffer if (argc >= 3 && JS_IsFunction(ctx, argv[2])) { // Save current render target and switch to off-screen @@ -1944,6 +1967,53 @@ static JSValue js_painting(JSContext *ctx, JSValueConst this_val, int argc, JSVa return painting; } +// page(painting) — switch render target to a painting buffer (or back to screen) +// page(painting) returns an object with wipe/ink/box/line/etc that draw into that buffer. +// In AC web, page() returns a chainable proxy. Here we just switch the graph target. +static JSValue js_page(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; + if (!current_rt || !current_rt->graph) return JS_UNDEFINED; + + if (argc < 1 || JS_IsUndefined(argv[0]) || JS_IsNull(argv[0])) { + // page() with no args or page(screen) — restore screen target + graph_page(current_rt->graph, current_rt->graph->screen); + } else { + // page(painting) — switch to painting buffer + ACFramebuffer *fb = JS_GetOpaque(argv[0], painting_class_id); + if (fb) graph_page(current_rt->graph, fb); + } + + // Return a page proxy object with wipe() so callers can do page(buf).wipe(...) + JSValue proxy = JS_NewObject(ctx); + JSValue global = JS_GetGlobalObject(ctx); + JS_SetPropertyStr(ctx, proxy, "wipe", JS_GetPropertyStr(ctx, global, "wipe")); + JS_SetPropertyStr(ctx, proxy, "ink", JS_GetPropertyStr(ctx, global, "ink")); + JS_SetPropertyStr(ctx, proxy, "box", JS_GetPropertyStr(ctx, global, "box")); + JS_SetPropertyStr(ctx, proxy, "line", JS_GetPropertyStr(ctx, global, "line")); + JS_SetPropertyStr(ctx, proxy, "circle", JS_GetPropertyStr(ctx, global, "circle")); + JS_SetPropertyStr(ctx, proxy, "plot", JS_GetPropertyStr(ctx, global, "plot")); + JS_SetPropertyStr(ctx, proxy, "write", JS_GetPropertyStr(ctx, global, "write")); + JS_FreeValue(ctx, global); + return proxy; +} + +// paste(painting, dx?, dy?) — alpha-composite a painting onto the current render target +static JSValue js_paste(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; + if (!current_rt || !current_rt->graph) return JS_UNDEFINED; + if (argc < 1) return JS_UNDEFINED; + + ACFramebuffer *src = JS_GetOpaque(argv[0], painting_class_id); + if (!src) return JS_UNDEFINED; + + int dx = 0, dy = 0; + if (argc >= 2) JS_ToInt32(ctx, &dx, argv[1]); + if (argc >= 3) JS_ToInt32(ctx, &dy, argv[2]); + + graph_paste(current_rt->graph, src, dx, dy); + return JS_UNDEFINED; +} + // sound.bpm(val?) — get or set BPM, returns current value static JSValue js_sound_bpm(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { (void)this_val; @@ -5545,9 +5615,9 @@ static JSValue build_api(JSContext *ctx, ACRuntime *rt, const char *phase) { // painting(w, h, callback) — creates a stub painting object with width/height JS_SetPropertyStr(ctx, api, "painting", JS_NewCFunction(ctx, js_painting, "painting", 3)); - // paste, page, layer, sharpen (stubs) - JS_SetPropertyStr(ctx, api, "paste", JS_NewCFunction(ctx, js_noop, "paste", 4)); - JS_SetPropertyStr(ctx, api, "page", JS_NewCFunction(ctx, js_noop, "page", 1)); + // paste, page (real implementations), layer, sharpen (stubs) + JS_SetPropertyStr(ctx, api, "paste", JS_NewCFunction(ctx, js_paste, "paste", 3)); + JS_SetPropertyStr(ctx, api, "page", JS_NewCFunction(ctx, js_page, "page", 1)); JS_SetPropertyStr(ctx, api, "layer", JS_NewCFunction(ctx, js_noop, "layer", 1)); JS_SetPropertyStr(ctx, api, "sharpen", JS_NewCFunction(ctx, js_noop, "sharpen", 1));