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

Support Vector Machines in Machine Learning

Support Vector Machines in Machine Learning Introduction Support Vector Machines (SVMs) are powerful supervised learning algorithms used ...