NinePatchRect

9-slice panel scaling.

▶ Run in browser

Tags: 2d

Generates a test panel texture with distinct corners, edges, and centre, then renders NinePatchRect nodes at various sizes to demonstrate that:

  • Corners maintain their original pixel size

  • Edges stretch in one direction only

  • Centre fills the remaining space

Shows the engine’s in-memory texture API: the texture property on NinePatchRect accepts an RGBA uint8 numpy.ndarray directly, no file I/O required.

--scene tiling runs a second scene comparing the three values of axis_stretch_horizontal / axis_stretch_vertical on a patterned border: stretch smears one copy of the artwork across the span, tile repeats it at its authored pixel size and clips the last repetition, and tile_fit repeats a whole number of times, resizing each repetition to divide the span exactly.

Source

  1"""NinePatchRect: 9-slice panel scaling.
  2
  3# /// simvx
  4# web = { width = 900, height = 600 }
  5# ///
  6
  7Generates a test panel texture with distinct corners, edges, and centre,
  8then renders NinePatchRect nodes at various sizes to demonstrate that:
  9  - Corners maintain their original pixel size
 10  - Edges stretch in one direction only
 11  - Centre fills the remaining space
 12
 13Shows the engine's in-memory texture API: the ``texture`` property on
 14NinePatchRect accepts an RGBA uint8 ``numpy.ndarray`` directly, no file
 15I/O required.
 16
 17``--scene tiling`` runs a second scene comparing the three values of
 18``axis_stretch_horizontal`` / ``axis_stretch_vertical`` on a patterned border:
 19``stretch`` smears one copy of the artwork across the span, ``tile`` repeats it
 20at its authored pixel size and clips the last repetition, and ``tile_fit``
 21repeats a whole number of times, resizing each repetition to divide the span
 22exactly.
 23"""
 24
 25import argparse
 26
 27import numpy as np
 28
 29from simvx.core import NinePatchRect, Node2D, Text2D, Vec2
 30from simvx.graphics import App
 31
 32
 33def _make_ninepatch_panel(size: int = 64, margin: int = 16) -> np.ndarray:
 34    """Generate a panel texture with visually distinct 9-slice regions.
 35
 36    Corners are bright red, edges are green (horizontal) / blue (vertical),
 37    and the centre is a dark grey. A 1px border outlines the whole texture.
 38    """
 39    img = np.zeros((size, size, 4), dtype=np.uint8)
 40
 41    for y in range(size):
 42        for x in range(size):
 43            in_left = x < margin
 44            in_right = x >= size - margin
 45            in_top = y < margin
 46            in_bottom = y >= size - margin
 47
 48            if (in_top or in_bottom) and (in_left or in_right):
 49                # Corners -- bright red/orange
 50                img[y, x] = [220, 80, 60, 255]
 51            elif in_top or in_bottom:
 52                # Horizontal edges -- green
 53                img[y, x] = [60, 180, 80, 255]
 54            elif in_left or in_right:
 55                # Vertical edges -- blue
 56                img[y, x] = [60, 100, 220, 255]
 57            else:
 58                # Centre -- dark grey
 59                img[y, x] = [80, 80, 90, 255]
 60
 61    # 1px border
 62    img[0, :] = [255, 255, 255, 255]
 63    img[-1, :] = [255, 255, 255, 255]
 64    img[:, 0] = [255, 255, 255, 255]
 65    img[:, -1] = [255, 255, 255, 255]
 66    return img
 67
 68
 69# ---------------------------------------------------------------------------
 70# Scene
 71# ---------------------------------------------------------------------------
 72
 73
 74class NinePatchScene(Node2D):
 75    """Root scene displaying NinePatchRect nodes at different sizes."""
 76
 77    def on_ready(self):
 78        margin = 16
 79
 80        # Generate the panel pixels in memory and hand the ndarray directly to
 81        # NinePatchRect.texture: the renderer uploads it via
 82        # TextureManager.resolve() / load_from_array().
 83        panel = _make_ninepatch_panel(64, margin)
 84
 85        # Small -- just larger than the margins
 86        self.add_child(
 87            NinePatchRect(
 88                texture=panel,
 89                size=(80, 60),
 90                patch_margin_left=margin,
 91                patch_margin_right=margin,
 92                patch_margin_top=margin,
 93                patch_margin_bottom=margin,
 94                position=Vec2(40, 60),
 95                name="Small",
 96            )
 97        )
 98
 99        # Medium -- typical button/panel size
100        self.add_child(
101            NinePatchRect(
102                texture=panel,
103                size=(250, 100),
104                patch_margin_left=margin,
105                patch_margin_right=margin,
106                patch_margin_top=margin,
107                patch_margin_bottom=margin,
108                position=Vec2(40, 160),
109                name="Medium",
110            )
111        )
112
113        # Large -- wide dialogue box
114        self.add_child(
115            NinePatchRect(
116                texture=panel,
117                size=(500, 200),
118                patch_margin_left=margin,
119                patch_margin_right=margin,
120                patch_margin_top=margin,
121                patch_margin_bottom=margin,
122                position=Vec2(40, 300),
123                name="Large",
124            )
125        )
126
127        # Tall narrow panel
128        self.add_child(
129            NinePatchRect(
130                texture=panel,
131                size=(80, 250),
132                patch_margin_left=margin,
133                patch_margin_right=margin,
134                patch_margin_top=margin,
135                patch_margin_bottom=margin,
136                position=Vec2(580, 60),
137                name="Tall",
138            )
139        )
140
141        # Labels
142        self.add_child(
143            Text2D(text="NinePatchRect Demo -- 9-Slice Scaling", position=(10, 10), font_scale=1.5, name="Title")
144        )
145        self.add_child(Text2D(text="Small (80x60)", position=(140, 75), name="LabelSmall"))
146        self.add_child(Text2D(text="Medium (250x100)", position=(300, 195), name="LabelMed"))
147        self.add_child(Text2D(text="Large (500x200)", position=(300, 385), name="LabelLarge"))
148        self.add_child(Text2D(text="Tall (80x250)", position=(580, 330), name="LabelTall"))
149
150
151def _make_patterned_panel(size: int = 64, margin: int = 16) -> np.ndarray:
152    """A panel whose borders carry a motif, so repeating it is visible.
153
154    The plain panel above has flat edge bands, and a flat band stretched looks
155    exactly like the same band tiled. Each edge here carries one bright dash in
156    the middle of its 32 px span, which a stretched span smears into one long
157    bar and a tiled span reproduces once per repetition.
158    """
159    img = np.zeros((size, size, 4), dtype=np.uint8)
160    inner = size - margin
161    img[:] = [40, 44, 56, 255]
162    img[:margin, :] = img[inner:, :] = [60, 180, 80, 255]
163    img[:, :margin] = img[:, inner:] = [60, 100, 220, 255]
164    # Corners last, so they win over both edge bands.
165    for ys in (slice(0, margin), slice(inner, size)):
166        for xs in (slice(0, margin), slice(inner, size)):
167            img[ys, xs] = [220, 80, 60, 255]
168
169    # One dash per edge band and one square in the centre, each centred in its
170    # span so a repetition is unambiguous to count.
171    mid = slice(size // 2 - 4, size // 2 + 4)
172    img[:margin, mid] = img[inner:, mid] = [255, 255, 255, 255]
173    img[mid, :margin] = img[mid, inner:] = [255, 255, 255, 255]
174    img[mid, mid] = [200, 200, 90, 255]
175    return img
176
177
178class TilingScene(Node2D):
179    """The same patterned panel at one size under each axis stretch mode.
180
181    Every panel is 200x150 with 16 px margins on a 64x64 source, so each edge
182    band has 32 source px to cover 168 horizontally and 118 vertically: 5.25 and
183    3.69 repetitions, neither of them whole. That is what separates the modes.
184    """
185
186    #: ``(mode, x, caption)``. Three panels side by side at one size, so the
187    #: only difference between them is the mode.
188    COLUMNS = (
189        ("stretch", 20, "one copy smeared"),
190        ("tile", 260, "5 whole + 1 clipped"),
191        ("tile_fit", 500, "5 equal, resized"),
192    )
193
194    #: ``(horizontal, vertical, x, label)``. The second row sets one axis at a
195    #: time, which is what shows that each mode governs its own three regions.
196    AXES = (
197        ("tile", "stretch", 20, "horizontal: top, bottom, centre"),
198        ("stretch", "tile", 260, "vertical: sides, centre"),
199        ("tile", "tile", 500, "both, draw_center=False"),
200    )
201
202    def on_ready(self):
203        margin = 16
204        panel = _make_patterned_panel(64, margin)
205
206        self.add_child(
207            Text2D(text="NinePatchRect -- axis stretch modes", position=(20, 15), font_scale=1.5, name="Title")
208        )
209        self.add_child(
210            Text2D(
211                text="Each panel is 200x150 from the same 64x64 source with 16 px margins.",
212                position=(20, 48),
213                name="Caption",
214            )
215        )
216        for mode, x, caption in self.COLUMNS:
217            self.add_child(Text2D(text=mode, position=(x, 90), font_scale=1.2, name=f"Label{mode}"))
218            self.add_child(Text2D(text=caption, position=(x, 118), name=f"Caption{mode}"))
219            self.add_child(
220                NinePatchRect(
221                    texture=panel,
222                    size=(200, 150),
223                    patch_margin_left=margin,
224                    patch_margin_right=margin,
225                    patch_margin_top=margin,
226                    patch_margin_bottom=margin,
227                    axis_stretch_horizontal=mode,
228                    axis_stretch_vertical=mode,
229                    position=Vec2(x, 150),
230                    name=f"Panel{mode}",
231                )
232            )
233
234        self.add_child(
235            Text2D(
236                text="Set one axis at a time: a mode governs only the three regions that grow along it.",
237                position=(20, 350),
238                name="AxesCaption",
239            )
240        )
241        for index, (horizontal, vertical, x, label) in enumerate(self.AXES):
242            self.add_child(Text2D(text=label, position=(x, 378), name=f"LabelAxes{index}"))
243            self.add_child(
244                NinePatchRect(
245                    texture=panel,
246                    size=(200, 150),
247                    patch_margin_left=margin,
248                    patch_margin_right=margin,
249                    patch_margin_top=margin,
250                    patch_margin_bottom=margin,
251                    axis_stretch_horizontal=horizontal,
252                    axis_stretch_vertical=vertical,
253                    # The last panel drops the centre, which is how a nine-patch
254                    # draws a frame over content it must not cover.
255                    draw_center=index < len(self.AXES) - 1,
256                    position=Vec2(x, 410),
257                    name=f"PanelAxes{index}",
258                )
259            )
260
261
262SCENES = {"panels": NinePatchScene, "tiling": TilingScene}
263
264
265if __name__ == "__main__":
266    parser = argparse.ArgumentParser(description="NinePatchRect reference scenes")
267    parser.add_argument("--scene", choices=tuple(SCENES), default="panels", help="which nine-patch scene to run")
268    name = parser.parse_args().scene
269    app = App(width=900, height=600, title=f"SimVX NinePatchRect Demo ({name})")
270    app.run(SCENES[name]())