Saturday, December 6, 2025

My Sister Ariyana's Lego Making Program

 

Features

  • Snap-to-grid canvas: Bricks align cleanly on a configurable grid.

  • Brick palette: Choose common sizes (1x1 to 6x2) and colors.

  • Drag & drop editing: Click a brick to select; drag to move; automatic collision feedback.

  • Rotate and delete: Use buttons or shortcuts (r, Delete).

  • Save/load projects: Persist your designs in JSON.

  • Export to PostScript: Create a printable vector snapshot of the canvas.

Source Code:

#!/usr/bin/env python3
# My sister Ariyana's lego making program
# Standard library + tkinter only

import json
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

GRID_SIZE = 32          # pixels per grid cell
CANVAS_COLS = 24        # horizontal grid cells
CANVAS_ROWS = 16        # vertical grid cells
STUD_SIZE = 8           # diameter of studs in pixels
STUD_MARGIN = 6         # margin inside brick for studs

DEFAULT_COLOR = "#FF5757"

BRICK_SIZES = [
    ("1x1", 1, 1),
    ("2x1", 2, 1),
    ("3x1", 3, 1),
    ("4x1", 4, 1),
    ("2x2", 2, 2),
    ("3x2", 3, 2),
    ("4x2", 4, 2),
    ("6x2", 6, 2),
]

PALETTE_COLORS = [
    "#FF5757", "#FFA500", "#FFD700", "#32CD32",
    "#1E90FF", "#8A2BE2", "#A52A2A", "#808080",
    "#FFFFFF", "#000000"
]


class Brick:
    def __init__(self, gx, gy, w, h, color):
        # grid coordinates, width and height in grid cells
        self.gx = gx
        self.gy = gy
        self.w = w
        self.h = h
        self.color = color
        self.item_ids = []  # canvas item ids for the brick drawing
        self.tag = f"brick_{id(self)}"

    def bounds_px(self):
        x0 = self.gx * GRID_SIZE
        y0 = self.gy * GRID_SIZE
        x1 = (self.gx + self.w) * GRID_SIZE
        y1 = (self.gy + self.h) * GRID_SIZE
        return x0, y0, x1, y1

    def rotate(self):
        self.w, self.h = self.h, self.w


class LegoApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("My sister Ariyana's lego making program")

        # State
        self.bricks = []            # list[Brick]
        self.selected_brick = None
        self.drag_offset = (0, 0)   # offset in pixels during dragging
        self.current_size = (2, 2)  # default brick size (w,h)
        self.current_color = DEFAULT_COLOR

        self._build_ui()
        self._draw_grid()

        # Keyboard shortcuts
        self.bind("<Delete>", self.delete_selected)
        self.bind("<BackSpace>", self.delete_selected)
        self.bind("<r>", self.rotate_selected)
        self.bind("<R>", self.rotate_selected)

    def _build_ui(self):
        # Top menu
        menubar = tk.Menu(self)
        file_menu = tk.Menu(menubar, tearoff=False)
        file_menu.add_command(label="New", command=self.new_project)
        file_menu.add_command(label="Open…", command=self.load_project)
        file_menu.add_command(label="Save As…", command=self.save_project)
        file_menu.add_separator()
        file_menu.add_command(label="Export PostScript…", command=self.export_postscript)
        file_menu.add_separator()
        file_menu.add_command(label="Quit", command=self.quit)
        menubar.add_cascade(label="File", menu=file_menu)

        edit_menu = tk.Menu(menubar, tearoff=False)
        edit_menu.add_command(label="Rotate (r)", command=self.rotate_selected)
        edit_menu.add_command(label="Delete (Del)", command=self.delete_selected)
        menubar.add_cascade(label="Edit", menu=edit_menu)

        self.config(menu=menubar)

        # Layout: left palette, right canvas
        root_frame = ttk.Frame(self)
        root_frame.pack(fill="both", expand=True)

        palette = ttk.Frame(root_frame)
        palette.pack(side="left", fill="y", padx=8, pady=8)

        canvas_frame = ttk.Frame(root_frame)
        canvas_frame.pack(side="right", fill="both", expand=True, padx=8, pady=8)

        # Canvas
        width = CANVAS_COLS * GRID_SIZE
        height = CANVAS_ROWS * GRID_SIZE
        self.canvas = tk.Canvas(canvas_frame, width=width, height=height, bg="#f7f7f7", highlightthickness=1, highlightbackground="#ccc")
        self.canvas.pack(fill="both", expand=True)
        self.canvas.bind("<Button-1>", self.on_canvas_click)
        self.canvas.bind("<ButtonPress-1>", self.on_canvas_press)
        self.canvas.bind("<B1-Motion>", self.on_canvas_drag)
        self.canvas.bind("<ButtonRelease-1>", self.on_canvas_release)

        # Palette: sizes
        ttk.Label(palette, text="Brick sizes", font=("TkDefaultFont", 10, "bold")).pack(anchor="w", pady=(0, 4))
        size_frame = ttk.Frame(palette)
        size_frame.pack(fill="x", pady=(0, 8))

        for i, (label, w, h) in enumerate(BRICK_SIZES):
            btn = ttk.Button(size_frame, text=label, command=lambda W=w, H=h: self.set_size(W, H))
            btn.grid(row=i // 2, column=i % 2, sticky="ew", padx=2, pady=2)
        for c in range(2):
            size_frame.grid_columnconfigure(c, weight=1)

        # Palette: colors
        ttk.Label(palette, text="Colors", font=("TkDefaultFont", 10, "bold")).pack(anchor="w", pady=(8, 4))
        color_frame = ttk.Frame(palette)
        color_frame.pack(fill="x", pady=(0, 8))
        for i, col in enumerate(PALETTE_COLORS):
            btn = tk.Button(color_frame, bg=col, activebackground=col, width=3, command=lambda C=col: self.set_color(C))
            btn.grid(row=i // 5, column=i % 5, padx=2, pady=2, sticky="ew")
        for c in range(5):
            color_frame.grid_columnconfigure(c, weight=1)

        # Actions
        ttk.Label(palette, text="Actions", font=("TkDefaultFont", 10, "bold")).pack(anchor="w", pady=(8, 4))
        actions = ttk.Frame(palette)
        actions.pack(fill="x")
        ttk.Button(actions, text="Add brick", command=self.add_brick_from_palette).pack(fill="x", pady=2)
        ttk.Button(actions, text="Rotate (r)", command=self.rotate_selected).pack(fill="x", pady=2)
        ttk.Button(actions, text="Delete (Del)", command=self.delete_selected).pack(fill="x", pady=2)
        ttk.Separator(palette).pack(fill="x", pady=8)
        ttk.Label(palette, text="Tip: Click a brick to drag; r=rotate.", foreground="#555").pack(anchor="w")

        # Status bar
        self.status = tk.StringVar(value="Ready")
        status_bar = ttk.Label(self, textvariable=self.status, anchor="w")
        status_bar.pack(fill="x", side="bottom")

    def _draw_grid(self):
        self.canvas.delete("grid")
        w = CANVAS_COLS * GRID_SIZE
        h = CANVAS_ROWS * GRID_SIZE
        # Outer border
        self.canvas.create_rectangle(0, 0, w, h, outline="#bbb", width=1, tags=("grid",))
        # Grid lines
        for x in range(0, w, GRID_SIZE):
            self.canvas.create_line(x, 0, x, h, fill="#e3e3e3", tags=("grid",))
        for y in range(0, h, GRID_SIZE):
            self.canvas.create_line(0, y, w, y, fill="#e3e3e3", tags=("grid",))

    def set_size(self, w, h):
        self.current_size = (w, h)
        self.status.set(f"Selected size: {w}x{h}")

    def set_color(self, color):
        self.current_color = color
        self.status.set(f"Selected color: {color}")

    def add_brick_from_palette(self):
        # place at top-left by default, snap if occupied
        gx, gy = self._find_first_free_spot(self.current_size[0], self.current_size[1])
        brick = Brick(gx, gy, self.current_size[0], self.current_size[1], self.current_color)
        self.bricks.append(brick)
        self._render_brick(brick)
        self._select_brick(brick)
        self.status.set(f"Brick added at {gx},{gy}")

    def _find_first_free_spot(self, w, h):
        # naive scan; avoid overlapping existing bricks
        for gy in range(CANVAS_ROWS - h + 1):
            for gx in range(CANVAS_COLS - w + 1):
                if not self._occupied(gx, gy, w, h):
                    return gx, gy
        # fallback to 0,0 if full
        return 0, 0

    def _occupied(self, gx, gy, w, h, exclude_brick=None):
        for b in self.bricks:
            if b is exclude_brick:
                continue
            if (gx < b.gx + b.w and gx + w > b.gx and
                gy < b.gy + b.h and gy + h > b.gy):
                return True
        return False

    def _render_brick(self, brick):
        # Remove old drawing
        for iid in brick.item_ids:
            self.canvas.delete(iid)
        brick.item_ids = []

        x0, y0, x1, y1 = brick.bounds_px()
        # Base rectangle
        base = self.canvas.create_rectangle(
            x0+1, y0+1, x1-1, y1-1,
            fill=brick.color, outline="#333", width=2,
            tags=(brick.tag, "brick")
        )
        brick.item_ids.append(base)

        # Simple shadow
        shade = self.canvas.create_line(
            x0+2, y1-2, x1-2, y1-2,
            fill="#000000", width=1, stipple="gray50",
            tags=(brick.tag, "brick")
        )
        brick.item_ids.append(shade)

        # Studs grid: one stud per 1x1 cell inside the brick
        cols = max(1, brick.w)
        rows = max(1, brick.h)
        cell_w = (x1 - x0) / cols
        cell_h = (y1 - y0) / rows
        stud_d = min(STUD_SIZE, int(min(cell_w, cell_h) - STUD_MARGIN))
        stud_r = stud_d / 2
        for r in range(rows):
            for c in range(cols):
                sx = x0 + c * cell_w + cell_w / 2
                sy = y0 + r * cell_h + cell_h / 2
                stud = self.canvas.create_oval(
                    sx - stud_r, sy - stud_r, sx + stud_r, sy + stud_r,
                    fill=self._lighter(brick.color, 0.25),
                    outline="#444",
                    tags=(brick.tag, "brick")
                )
                brick.item_ids.append(stud)

        # Raise selection outline if selected
        if self.selected_brick is brick:
            self._draw_selection_outline(brick)

        # Bind clicks to select
        for iid in brick.item_ids:
            self.canvas.tag_bind(iid, "<Button-1>", self.on_brick_click)

    def _draw_selection_outline(self, brick):
        # Remove previous selection outlines
        self.canvas.delete("selection")
        x0, y0, x1, y1 = brick.bounds_px()
        outline = self.canvas.create_rectangle(
            x0+2, y0+2, x1-2, y1-2,
            outline="#00a3ff", width=2, dash=(4, 2),
            tags=("selection",)
        )
        return outline

    def _select_brick(self, brick):
        self.selected_brick = brick
        self._draw_selection_outline(brick)

    def on_brick_click(self, event):
        # Find brick via overlapping items
        items = self.canvas.find_overlapping(event.x, event.y, event.x, event.y)
        for iid in items:
            tags = self.canvas.gettags(iid)
            for t in tags:
                if t.startswith("brick_"):
                    # map tag to brick
                    for b in self.bricks:
                        if b.tag == t:
                            self._select_brick(b)
                            self.status.set("Brick selected")
                            return

    def on_canvas_click(self, event):
        # Clicking empty canvas clears selection
        items = self.canvas.find_overlapping(event.x, event.y, event.x, event.y)
        if not items:
            self.selected_brick = None
            self.canvas.delete("selection")
            self.status.set("No selection")

    def on_canvas_press(self, event):
        if self.selected_brick is None:
            return
        x0, y0, x1, y1 = self.selected_brick.bounds_px()
        if x0 <= event.x <= x1 and y0 <= event.y <= y1:
            self.drag_offset = (event.x - x0, event.y - y0)
        else:
            self.drag_offset = (0, 0)

    def on_canvas_drag(self, event):
        if self.selected_brick is None:
            return
        # Move brick visually during drag (not snapped yet)
        dx = event.x - self.drag_offset[0]
        dy = event.y - self.drag_offset[1]
        # Convert to grid position (float), then clamp to canvas
        gx = max(0, min(CANVAS_COLS - self.selected_brick.w, int(round(dx / GRID_SIZE))))
        gy = max(0, min(CANVAS_ROWS - self.selected_brick.h, int(round(dy / GRID_SIZE))))
        # Temporarily set position and render
        old_gx, old_gy = self.selected_brick.gx, self.selected_brick.gy
        self.selected_brick.gx, self.selected_brick.gy = gx, gy
        if self._occupied(gx, gy, self.selected_brick.w, self.selected_brick.h, exclude_brick=self.selected_brick):
            # show as red outline to indicate collision
            self._render_brick(self.selected_brick)
            self.canvas.delete("selection")
            x0, y0, x1, y1 = self.selected_brick.bounds_px()
            self.canvas.create_rectangle(x0+2, y0+2, x1-2, y1-2, outline="#ff0033", width=2, dash=(2, 2), tags=("selection",))
        else:
            self._render_brick(self.selected_brick)
        # Restore to be persistent on release
        self.selected_brick.gx, self.selected_brick.gy = old_gx, old_gy

    def on_canvas_release(self, event):
        if self.selected_brick is None:
            return
        # Snap to nearest grid
        gx = int(event.x // GRID_SIZE)
        gy = int(event.y // GRID_SIZE)
        gx = max(0, min(CANVAS_COLS - self.selected_brick.w, gx))
        gy = max(0, min(CANVAS_ROWS - self.selected_brick.h, gy))
        if not self._occupied(gx, gy, self.selected_brick.w, self.selected_brick.h, exclude_brick=self.selected_brick):
            self.selected_brick.gx = gx
            self.selected_brick.gy = gy
            self._render_brick(self.selected_brick)
            self._draw_selection_outline(self.selected_brick)
            self.status.set(f"Moved to {gx},{gy}")
        else:
            self.status.set("Cannot place: space occupied")

    def rotate_selected(self, event=None):
        b = self.selected_brick
        if not b:
            return
        # rotate if fits
        b.rotate()
        b.gx = min(b.gx, CANVAS_COLS - b.w)
        b.gy = min(b.gy, CANVAS_ROWS - b.h)
        if self._occupied(b.gx, b.gy, b.w, b.h, exclude_brick=b):
            b.rotate()  # revert
            self.status.set("Rotation blocked: collision")
            return
        self._render_brick(b)
        self._draw_selection_outline(b)
        self.status.set("Rotated")

    def delete_selected(self, event=None):
        b = self.selected_brick
        if not b:
            return
        for iid in b.item_ids:
            self.canvas.delete(iid)
        self.canvas.delete("selection")
        self.bricks.remove(b)
        self.selected_brick = None
        self.status.set("Brick deleted")

    def new_project(self):
        if not self._confirm_discard_changes():
            return
        for b in list(self.bricks):
            for iid in b.item_ids:
                self.canvas.delete(iid)
        self.canvas.delete("selection")
        self.bricks.clear()
        self.selected_brick = None
        self.status.set("New project")

    def save_project(self):
        data = {
            "version": 1,
            "grid_size": GRID_SIZE,
            "cols": CANVAS_COLS,
            "rows": CANVAS_ROWS,
            "bricks": [
                {"gx": b.gx, "gy": b.gy, "w": b.w, "h": b.h, "color": b.color}
                for b in self.bricks
            ]
        }
        path = filedialog.asksaveasfilename(
            title="Save Project",
            defaultextension=".json",
            filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")]
        )
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2)
            self.status.set(f"Saved: {path}")
        except Exception as e:
            messagebox.showerror("Save Error", str(e))

    def load_project(self):
        if not self._confirm_discard_changes():
            return
        path = filedialog.askopenfilename(
            title="Open Project",
            filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")]
        )
        if not path:
            return
        try:
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
            self.bricks.clear()
            self.canvas.delete("all")
            self._draw_grid()
            for bdata in data.get("bricks", []):
                b = Brick(bdata["gx"], bdata["gy"], bdata["w"], bdata["h"], bdata["color"])
                self.bricks.append(b)
                self._render_brick(b)
            self.selected_brick = None
            self.status.set(f"Loaded: {path}")
        except Exception as e:
            messagebox.showerror("Load Error", str(e))

    def export_postscript(self):
        path = filedialog.asksaveasfilename(
            title="Export Canvas",
            defaultextension=".ps",
            filetypes=[("PostScript", "*.ps"), ("All Files", "*.*")]
        )
        if not path:
            return
        try:
            self.canvas.postscript(file=path, colormode="color")
            self.status.set(f"Exported: {path}")
        except Exception as e:
            messagebox.showerror("Export Error", str(e))

    def _lighter(self, hex_color, amount=0.2):
        # Simple lighten function (0..1)
        hex_color = hex_color.lstrip("#")
        r = int(hex_color[0:2], 16)
        g = int(hex_color[2:4], 16)
        b = int(hex_color[4:6], 16)
        r = int(r + (255 - r) * amount)
        g = int(g + (255 - g) * amount)
        b = int(b + (255 - b) * amount)
        return f"#{r:02X}{g:02X}{b:02X}"

    def _confirm_discard_changes(self):
        if not self.bricks:
            return True
        return messagebox.askyesno("Discard changes?", "This will clear the current canvas. Continue?")

def main():
    app = LegoApp()
    app.mainloop()

if __name__ == "__main__":
    main()

No comments:

Post a Comment

Mini RDBMS (with persistent storage) using only Python Standard Library

Mini RDBMS (with persistent storage) using only the Python Standard Library import re import json import os from typing import Any, Dict, Li...