From 584cfbb05e8fbba6f27ae3637dbe6596f26f5232 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 1 Sep 2026 12:53:47 -0700 Subject: [PATCH] Add drawRoundedImage for rounded-corner image drawing Builds the shape from an inner rect, four edge rects and four triangle fans, with UVs linearly mapped from the destination rect into the atlas entry so the texture is continuous across pieces. Fan segment count is arc-length based (~2px per segment, uiScale aware) so corners stay smooth at any radius. Radius clamps to min(w, h) / 2 and falls back to a plain quad when near zero. Adds tests/manual_rounded_image.nim with width/height/radius scrubbers and a --screenshot mode that dumps the framebuffer to tmp/rounded_screenshot.png, plus the silkyTesting stub. Co-Authored-By: Claude Fable 5 --- src/silky/contexts.nim | 72 +++++++++++++++++++ src/silky/semantics.nim | 11 +++ tests/manual_rounded_image.nim | 126 +++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 tests/manual_rounded_image.nim diff --git a/src/silky/contexts.nim b/src/silky/contexts.nim index 402c777..0d623a3 100644 --- a/src/silky/contexts.nim +++ b/src/silky/contexts.nim @@ -780,6 +780,78 @@ proc draw9Patch*( ## Queues a 9-patch image draw. sk.draw9Patch(name, patch, patch, patch, patch, pos, size, color) +proc drawRoundedImage*( + sk: Silky, + name: string, + pos: Vec2, + size: Vec2, + radius: float32, + color = rgbx(255, 255, 255, 255) +) {.measure.} = + ## Queues an atlas image draw stretched to size with rounded corners. + ## Builds the shape from an inner rect, four edge rects and four + ## triangle fans, keeping UVs mapped to the destination rect throughout. + if name notin sk.atlas.entries: + echo "[Warning] Sprite not found in atlas: " & name + return + if size.x <= 0.001 or size.y <= 0.001: + return + let + entry = sk.atlas.entries[name] + uvOrigin = vec2(entry.x.float32, entry.y.float32) + uvScale = vec2( + entry.width.float32 / size.x, + entry.height.float32 / size.y + ) + r = clamp(radius, 0.0'f, min(size.x, size.y) / 2) + + template uvAt(p: Vec2): Vec2 = + uvOrigin + (p - pos) * uvScale + + template piece(piecePos, pieceSize: Vec2) = + if pieceSize.x > 0.001 and pieceSize.y > 0.001: + sk.drawQuad( + piecePos, + pieceSize, + uvAt(piecePos), + pieceSize * uvScale, + color + ) + + if r < 0.5: + piece(pos, size) + return + + # Inner rect plus the four edge rects between the corners. + piece(pos + vec2(r, r), size - vec2(r, r) * 2) + piece(pos + vec2(r, 0), vec2(size.x - r * 2, r)) + piece(pos + vec2(r, size.y - r), vec2(size.x - r * 2, r)) + piece(pos + vec2(0, r), vec2(r, size.y - r * 2)) + piece(pos + vec2(size.x - r, r), vec2(r, size.y - r * 2)) + + # Quarter-circle fans, one segment per ~2px of arc on screen. + let + arcLength = r * PI.float32 / 2 * max(sk.uiScale, 1.0'f) + segments = max(4, ceil(arcLength / 2).int) + corners = [ + (pos + vec2(r, r), PI.float32), + (pos + vec2(size.x - r, r), PI.float32 * 1.5), + (pos + vec2(size.x - r, size.y - r), 0.0'f), + (pos + vec2(r, size.y - r), PI.float32 * 0.5) + ] + for (pivot, startAngle) in corners: + var previous = pivot + vec2(cos(startAngle), sin(startAngle)) * r + for i in 1 .. segments: + let + angle = startAngle + PI.float32 / 2 * i.float32 / segments.float32 + current = pivot + vec2(cos(angle), sin(angle)) * r + sk.drawTriangle( + [pivot, previous, current], + [uvAt(pivot), uvAt(previous), uvAt(current)], + [color, color, color] + ) + previous = current + proc contains*(sk: Silky, name: string): bool = ## Returns true if the atlas contains one image entry. name in sk.atlas.entries diff --git a/src/silky/semantics.nim b/src/silky/semantics.nim index 36b0de5..b05e721 100644 --- a/src/silky/semantics.nim +++ b/src/silky/semantics.nim @@ -482,6 +482,17 @@ proc drawTriangle*( ) {.inline.} = discard +proc drawRoundedImage*( + sk: Silky, + name: string, + pos: Vec2, + size: Vec2, + radius: float32, + color = rgbx(255, 255, 255, 255) +) {.inline.} = + ## Stub for drawing an image with rounded corners. + discard + proc draw9Patch*(sk: Silky, name: string, patch: int, pos: Vec2, size: Vec2, color = rgbx(255, 255, 255, 255)) {.inline.} = ## Stub for drawing a 9-patch image. discard diff --git a/tests/manual_rounded_image.nim b/tests/manual_rounded_image.nim new file mode 100644 index 0000000..3bd7d76 --- /dev/null +++ b/tests/manual_rounded_image.nim @@ -0,0 +1,126 @@ +## Manual test for drawRoundedImage with adjustable radius sliders. +## Pass --screenshot to render one frame to tmp/rounded_screenshot.png and exit. + +import + std/[strformat, os], + opengl, windy, vmath, chroma, pixie, + silky + +let builder = newAtlasBuilder(1024, 4) +builder.addDir("tests/data/", "tests/data/") +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "H1", 32.0) +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) +builder.write("tests/dist/atlas.png") + +let window = newWindow( + "Rounded Image Test", + ivec2(900, 700), + vsync = false +) +makeContextCurrent(window) +loadExtensions() + +let sk = newSilky(window, "tests/dist/atlas.png") + +var + cornerRadius = 32.0f + drawWidth = 300.0f + drawHeight = 200.0f + +proc drawFrame() = + sk.beginUI(window, window.size) + sk.clearScreen(rgbx(40, 40, 40, 255)) + + const + Margin = 20.0f + LabelWidth = 80.0f + SliderWidth = 300.0f + + ui: + text "title": + box Margin, Margin, 400, 24 + characters "Rounded Image Manual Test" + + let refX = Margin + LabelWidth + SliderWidth + Margin + text "original label": + box refX, 55, 200, 24 + characters "Original:" + sk.drawImage("debug.9patch", vec2(refX, 80)) + + group "controls": + box Margin, 55, LabelWidth + SliderWidth, 140 + layout TopToBottom + itemSpacing 12 + + group "width row": + box LabelWidth + SliderWidth, 24 + layout LeftToRight + text "width label": + box LabelWidth, 24 + characters "Width:" + group "width slider": + box SliderWidth, 24 + scrubber("width", drawWidth, 32.0, 600.0, &"{drawWidth:.0f}") + + group "height row": + box LabelWidth + SliderWidth, 24 + layout LeftToRight + text "height label": + box LabelWidth, 24 + characters "Height:" + group "height slider": + box SliderWidth, 24 + scrubber("height", drawHeight, 32.0, 600.0, &"{drawHeight:.0f}") + + group "radius row": + box LabelWidth + SliderWidth, 24 + layout LeftToRight + text "radius label": + box LabelWidth, 24 + characters "Radius:" + group "radius slider": + box SliderWidth, 24 + scrubber("radius", cornerRadius, 0.0, 300.0, &"{cornerRadius:.0f}") + + # Draw the rounded image below the controls. + sk.drawRoundedImage( + "debug.9patch", + vec2(Margin, 220), + vec2(drawWidth, drawHeight), + cornerRadius + ) + + let ms = sk.avgFrameTime * 1000 + text "frame time": + box sk.size.x - 250, 20, 230, 22 + characters &"frame time: {ms:>7.3f}ms" + + sk.endUi() + +window.onFrame = proc() = + drawFrame() + window.swapBuffers() + +if "--screenshot" in commandLineParams(): + pollEvents() + drawFrame() + let fb = window.size + var pixels = newSeq[uint8](fb.x * fb.y * 4) + glPixelStorei(GL_PACK_ALIGNMENT, 1) + glReadPixels( + 0, 0, fb.x, fb.y, + GL_RGBA, GL_UNSIGNED_BYTE, + addr pixels[0] + ) + let shot = newImage(fb.x, fb.y) + for y in 0 ..< fb.y.int: + for x in 0 ..< fb.x.int: + let i = ((fb.y.int - 1 - y) * fb.x.int + x) * 4 + shot.data[y * fb.x.int + x] = + rgbx(pixels[i], pixels[i + 1], pixels[i + 2], 255) + shot.writeFile("tmp/rounded_screenshot.png") + echo "Wrote tmp/rounded_screenshot.png" + quit(0) + +while not window.closeRequested: + pollEvents()