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