#!/usr/bin/env python3 """Terrain editor GUI — draw pyramids, mark spawn zones, export PNG + coordinates. Usage: uv run scripts/terrain_editor.py """ import tkinter as tk from tkinter import ttk, messagebox import numpy as np, os, cv2 # ═══ defaults ═══ HS = 0.05; VS = 0.005 CELL_M = 8.0; BORDER_M = 5.0 PLATFORM_M = 1.0 # platform 1m SPAWN_RADIUS_M = 0.5 # spawn zone ±0.5m around center DEFAULT_STEP_H = 0.20; DEFAULT_STEP_D = 0.20; DEFAULT_NUM_STEPS = 10 DEFAULT_REF_PLANE_CM = 200 # all cells start from same reference height CELL_PX = int(CELL_M / HS); BORDER_PX = int(BORDER_M / HS) PLATFORM_PX = int(PLATFORM_M / HS); SPAWN_RADIUS_PX = int(SPAWN_RADIUS_M / HS) class TerrainEditor: def __init__(self, root): self.root = root self.root.title("Terrain Editor") self.rows = 2; self.cols = 4 self.cell_types = {} self._init_defaults() self.selected = (0, 0) self._dragging = False self._build_ui() self._sync_params() self._redraw_all() def _init_defaults(self): for r in range(self.rows): for c in range(self.cols): if r == 0: self.cell_types[(r, c)] = {"type": "flat", "spawn": True, "level": 0, "ref_plane_cm": DEFAULT_REF_PLANE_CM} else: self.cell_types[(r, c)] = { "type": "convex" if c % 2 == 0 else "concave", "step_h": DEFAULT_STEP_H, "step_d": DEFAULT_STEP_D, "num_steps": DEFAULT_NUM_STEPS, "spawn": True, "level": 1, "ref_plane_cm": DEFAULT_REF_PLANE_CM, } # ═══ UI ═══ def _build_ui(self): paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL) paned.pack(fill=tk.BOTH, expand=True) left = ttk.Frame(paned); paned.add(left, weight=2) right = ttk.Frame(paned); paned.add(right, weight=1) self._build_preview_ui(left) self._build_params_ui(right) def _build_preview_ui(self, parent): ttk.Label(parent, text="Terrain Preview (click to select cell, right-click toggle spawn)", font=("", 10)).pack(pady=2) self.info_label = ttk.Label(parent, text="") self.info_label.pack() self.preview = tk.Canvas(parent, bg="#333", width=600, height=400) self.preview.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.preview.bind("", self._on_click) self.preview.bind("", self._on_drag) self.preview.bind("", self._on_right_click) ttk.Label(parent, text="Left-click: select | Right-click: toggle spawn | Drag: select").pack() ctrl = ttk.Frame(parent) ctrl.pack(pady=5) ttk.Label(ctrl, text="Rows:").pack(side=tk.LEFT) self.rows_var = tk.IntVar(value=self.rows) ttk.Spinbox(ctrl, from_=1, to=10, width=4, textvariable=self.rows_var, command=self._on_grid_size).pack(side=tk.LEFT, padx=2) ttk.Label(ctrl, text="Cols:").pack(side=tk.LEFT, padx=(10,0)) self.cols_var = tk.IntVar(value=self.cols) ttk.Spinbox(ctrl, from_=1, to=10, width=4, textvariable=self.cols_var, command=self._on_grid_size).pack(side=tk.LEFT, padx=2) ttk.Button(parent, text="Export PNG + Coords", command=self._export).pack(pady=5) def _build_params_ui(self, parent): f = ttk.Frame(parent); f.pack(padx=10, pady=5, fill=tk.X) ttk.Label(f, text="Cell Type:").grid(row=0, column=0, sticky=tk.W) self.type_var = tk.StringVar(value="flat") ttk.Combobox(f, textvariable=self.type_var, values=["flat", "convex", "concave"], state="readonly", width=10).grid(row=0, column=1, padx=5) self.type_var.trace("w", lambda *a: self._on_param_change()) ttk.Label(f, text="Level:").grid(row=0, column=2, sticky=tk.W, padx=(20,0)) self.level_var = tk.IntVar(value=0) ttk.Spinbox(f, from_=0, to=9, width=3, textvariable=self.level_var, command=self._on_param_change).grid(row=0, column=3) self.spawn_var = tk.BooleanVar(value=True) ttk.Checkbutton(f, text="Spawn", variable=self.spawn_var, command=self._on_param_change).grid(row=0, column=4, padx=10) ttk.Separator(parent, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5, padx=10) ttk.Label(parent, text="Pyramid Params").pack() g = ttk.Frame(parent); g.pack(padx=10, pady=5, fill=tk.X) ttk.Label(g, text="Step height (m):").grid(row=0, column=0, sticky=tk.W) self.sh_var = tk.StringVar(value=str(DEFAULT_STEP_H)) ttk.Entry(g, textvariable=self.sh_var, width=7).grid(row=0, column=1, padx=5) self.sh_var.trace("w", lambda *a: self._on_param_change()) ttk.Label(g, text="Step tread (m):").grid(row=1, column=0, sticky=tk.W) self.sd_var = tk.StringVar(value=str(DEFAULT_STEP_D)) ttk.Entry(g, textvariable=self.sd_var, width=7).grid(row=1, column=1, padx=5) self.sd_var.trace("w", lambda *a: self._on_param_change()) ttk.Label(g, text="Num steps:").grid(row=2, column=0, sticky=tk.W) self.ns_var = tk.StringVar(value=str(DEFAULT_NUM_STEPS)) ttk.Entry(g, textvariable=self.ns_var, width=7).grid(row=2, column=1, padx=5) ttk.Label(g, text="Ref plane (cm):").grid(row=3, column=0, sticky=tk.W) self.ref_var = tk.StringVar(value=str(DEFAULT_REF_PLANE_CM)) ttk.Entry(g, textvariable=self.ref_var, width=7).grid(row=3, column=1, padx=5) self.ns_var.trace("w", lambda *a: self._on_param_change()) ttk.Separator(parent, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5, padx=10) ttk.Label(parent, text="Selected Cell").pack() self.cell_label = ttk.Label(parent, text="") self.cell_label.pack() # ═══ events ═══ def _cell_at(self, ex, ey): m = 5; pw = self.preview.winfo_width(); ph = self.preview.winfo_height() cw = (pw - 2*m) // max(self.cols,1); ch = (ph - 2*m) // max(self.rows,1) col = (ex - m) // cw; row = (ey - m) // ch if 0 <= col < self.cols and 0 <= row < self.rows: return row, col, m + col*cw, m + row*ch, cw, ch return None def _on_click(self, event): v = self._cell_at(event.x, event.y) if v: self.selected = (v[0], v[1]) self._sync_params(); self._redraw_all() def _on_drag(self, event): v = self._cell_at(event.x, event.y) if v: self.selected = (v[0], v[1]) self._sync_params(); self._redraw_all() def _on_right_click(self, event): v = self._cell_at(event.x, event.y) if v: r, c = v[0], v[1] ct = self.cell_types.get((r, c), {"type": "flat", "spawn": True, "level": r}) ct = dict(ct) # copy before modifying ct["spawn"] = not ct.get("spawn", True) self.cell_types[(r, c)] = ct if (r, c) == self.selected: self._sync_params() self._redraw_all() def _on_grid_size(self): try: nr = self.rows_var.get() except: nr = self.rows try: nc = self.cols_var.get() except: nc = self.cols if nr == self.rows and nc == self.cols: return old = self.cell_types self.rows, self.cols = nr, nc self.cell_types = {} for r in range(nr): for c in range(nc): self.cell_types[(r,c)] = old.get((r,c), {"type": "flat", "spawn": True, "level": r}) self._redraw_all() # ═══ sync ═══ def _sync_params(self): ct = self.cell_types.get(self.selected, {"type": "flat", "spawn": True, "level": self.selected[0]}) self.type_var.set(ct.get("type", "flat")) self.spawn_var.set(ct.get("spawn", True)) r = self.selected[0] self.level_var.set(ct.get("level", r)) self.ref_var.set(str(ct.get("ref_plane_cm", DEFAULT_REF_PLANE_CM))) self.sh_var.set(str(ct.get("step_h", DEFAULT_STEP_H))) self.sd_var.set(str(ct.get("step_d", DEFAULT_STEP_D))) self.ns_var.set(str(ct.get("num_steps", DEFAULT_NUM_STEPS))) r, c = self.selected half_x = BORDER_M + self.cols * CELL_M / 2 half_y = BORDER_M + self.rows * CELL_M / 2 cx = -half_x + BORDER_M + c * CELL_M + CELL_M / 2 cy = half_y - BORDER_M - r * CELL_M - CELL_M / 2 self.cell_label.config(text=f"({r},{c}) center: x={cx:+.1f} y={cy:+.1f} type={ct['type']}") def _on_param_change(self): r, c = self.selected try: sh = float(self.sh_var.get()); sd = float(self.sd_var.get()) except: return try: ns = int(self.ns_var.get()) except: return try: ref_cm = float(self.ref_var.get()) except: ref_cm = DEFAULT_REF_PLANE_CM cell = {"type": self.type_var.get(), "spawn": self.spawn_var.get(), "level": self.level_var.get(), "ref_plane_cm": ref_cm} if cell["type"] != "flat": cell.update({"step_h": sh, "step_d": sd, "num_steps": ns}) self.cell_types[(r, c)] = cell self._redraw_all() # ═══ draw ═══ def _redraw_all(self): w = self.preview.winfo_width(); h = self.preview.winfo_height() if w < 10: w = 600 if h < 10: h = 400 self._draw_preview(w, h) half_x = BORDER_M + self.cols * CELL_M / 2 half_y = BORDER_M + self.rows * CELL_M / 2 self.info_label.config( text=f"{self.rows}×{self.cols} " f"{self.cols*CELL_M+2*BORDER_M:.0f}×{self.rows*CELL_M+2*BORDER_M:.0f}m " f"spawn_cy = {half_y-BORDER_M-CELL_M/2:.0f} - row*{CELL_M:.0f}") def _draw_preview(self, pw, ph): cv = self.preview; cv.delete("all") m = 5; cw = (pw - 2*m) // max(self.cols, 1); ch = (ph - 2*m) // max(self.rows, 1) cw = max(cw, 30); ch = max(ch, 30) colors = {"flat": "#5b8c5a", "convex": "#c0392b", "concave": "#2471a3"} for r in range(self.rows): for c in range(self.cols): x1, y1 = m + c*cw, m + r*ch x2, y2 = x1 + cw, y1 + ch ct = self.cell_types.get((r,c), {"type": "flat", "spawn": True, "level": r}) cv.create_rectangle(x1, y1, x2, y2, fill=colors.get(ct["type"], "#555"), outline="#888", width=1) # Cell center dot cx = (x1+x2)//2; cy = (y1+y2)//2 cv.create_oval(cx-3, cy-3, cx+3, cy+3, fill="white", outline="") # Pyramid stairs rings if ct["type"] != "flat": sh = ct.get("step_h", DEFAULT_STEP_H) sd = ct.get("step_d", DEFAULT_STEP_D) ns = ct.get("num_steps", DEFAULT_NUM_STEPS) concave = ct["type"] == "concave" p2 = max(2, cw // 16) step_px = max(1, (cw//2 - p2) // max(ns, 1)) h_max = int(sh * ns / VS) for i in range(ns + 1): half = p2 + (ns - i) * step_px if concave: frac = (ns - i) / max(ns, 1) else: frac = i / max(ns, 1) g = int(180 - frac * 100) clr = f"#{g:02x}{g:02x}{g:02x}" cv.create_rectangle(cx - half, cy - half, cx + half, cy + half, fill=clr, outline="") # Spawn zone (green rect) if ct.get("spawn", True): sz = max(2, int(SPAWN_RADIUS_M / CELL_M * cw)) cv.create_rectangle(cx - sz, cy - sz, cx + sz, cy + sz, outline="#00ff00", width=2) # Level + type label lvl = ct.get("level", r) lbl = f"L{lvl} {ct['type'][:3]}" if ct["type"] != "flat": lbl = f"L{lvl} {ct['type'][:3]}-{sh*100:.0f}cm" cv.create_text(x1 + 20, y1 + 10, text=lbl, fill="white", font=("", 8), anchor=tk.NW) # Highlight selected cell r, c = self.selected x1, y1 = m + c*cw, m + r*ch x2, y2 = x1 + cw, y1 + ch cv.create_rectangle(x1, y1, x2, y2, outline="yellow", width=3) # Level labels on right for r in range(self.rows): y = m + r*ch + ch//2 cv.create_text(pw - 15, y, text=f"L{r}", fill="white", font=("", 12, "bold")) # ═══ generate + export ═══ def _generate_png(self): tot_rows = self.rows * CELL_PX + 2 * BORDER_PX tot_cols = self.cols * CELL_PX + 2 * BORDER_PX canvas = np.zeros((tot_rows, tot_cols), dtype=np.uint16) for r in range(self.rows): for c in range(self.cols): x0 = BORDER_PX + c * CELL_PX; y0 = BORDER_PX + r * CELL_PX ct = self.cell_types.get((r,c), {"type": "flat", "level": r}) if ct["type"] == "flat": continue sh = ct.get("step_h", DEFAULT_STEP_H) sd = ct.get("step_d", DEFAULT_STEP_D) ns = ct.get("num_steps", DEFAULT_NUM_STEPS) concave = ct["type"] == "concave" ref_cm = ct.get("ref_plane_cm", DEFAULT_REF_PLANE_CM) ref_vs = int(ref_cm / 100.0 / VS) h_vs = int(sh / VS); d_px = int(sd / HS) p2 = PLATFORM_PX // 2 cx = x0 + CELL_PX // 2; cy = y0 + CELL_PX // 2 cv2.rectangle(canvas, (x0, y0), (x0+CELL_PX, y0+CELL_PX), int(ref_vs), -1) for i in range(ns + 1): half = p2 + (ns - i) * d_px x1, y1 = cx - half, cy - half; x2, y2 = cx + half, cy + half if concave: h = ref_vs - h_vs * i else: h = ref_vs + h_vs * i cv2.rectangle(canvas, (x1, y1), (x2, y2), int(h), -1) hf_m = canvas.astype(np.float32) * VS z_min, z_max = float(hf_m.min()), float(hf_m.max()) z_range = max(z_max - z_min, 0.001) png = ((hf_m - z_min) / z_range * 65535).astype(np.uint16) return png, z_range, z_min def _export(self): png, z_range, z_min = self._generate_png() out_dir = os.path.join(os.path.dirname(__file__), "..", "motrix_envs", "src", "motrix_envs", "locomotion", "go1", "xmls", "assets") os.makedirs(out_dir, exist_ok=True) out_path = os.path.join(out_dir, "flat_stairs.png") cv2.imwrite(out_path, png) w_m = (self.cols * CELL_PX + 2 * BORDER_PX) * HS h_m = (self.rows * CELL_PX + 2 * BORDER_PX) * HS half_x = BORDER_M + self.cols * CELL_M / 2 half_y = BORDER_M + self.rows * CELL_M / 2 lines = [ f"# Terrain: {self.rows}×{self.cols} {w_m:.0f}×{h_m:.0f}m", f"XML: size=\"{w_m/2:.1f} {h_m/2:.1f} {z_range:.3f} {max(z_min,0.001):.3f}\"", f"dreamwaq.py: terrain_rows={self.rows} terrain_cols={self.cols}", f"", f"# === Cell centers ===", ] for r in range(self.rows): for c in range(self.cols): cx = -half_x + BORDER_M + c * CELL_M + CELL_M / 2 cy = half_y - BORDER_M - r * CELL_M - CELL_M / 2 ct = self.cell_types.get((r,c), {"type": "flat", "level": r}) lines.append(f" ({r},{c}): x={cx:+.1f} y={cy:+.1f} {ct['type']}") lines.append("") lines.append("# === Level boundaries (robot out of bounds → reset) ===") level_bounds = {} for r in range(self.rows): for c in range(self.cols): lv = self.cell_types.get((r,c), {"level": r})["level"] if lv not in level_bounds: level_bounds[lv] = {"rmin": r, "rmax": r, "cmin": c, "cmax": c} else: b = level_bounds[lv] b["rmin"] = min(b["rmin"], r) b["rmax"] = max(b["rmax"], r) b["cmin"] = min(b["cmin"], c) b["cmax"] = max(b["cmax"], c) for lv in sorted(level_bounds): b = level_bounds[lv] x_min = -half_x + BORDER_M + b["cmin"] * CELL_M x_max = -half_x + BORDER_M + (b["cmax"] + 1) * CELL_M y_min = half_y - BORDER_M - (b["rmax"] + 1) * CELL_M y_max = half_y - BORDER_M - b["rmin"] * CELL_M lines.append(f" level {lv}: x=[{x_min:+.1f}, {x_max:+.1f}] " f"y=[{y_min:+.1f}, {y_max:+.1f}] " f"({b['rmax']-b['rmin']+1}×{b['cmax']-b['cmin']+1} cells)") lines.append("") lines.append("# === Spawn positions ===") for lv in sorted(level_bounds): b = level_bounds[lv] spawn_cells = [(r,c) for r in range(b["rmin"], b["rmax"]+1) for c in range(b["cmin"], b["cmax"]+1) if self.cell_types.get((r,c), {}).get("spawn", True)] if spawn_cells: lines.append(f" level {lv}: {len(spawn_cells)} spawn cells") for (rr, cc) in spawn_cells: cx = -half_x + BORDER_M + cc * CELL_M + CELL_M / 2 cy = half_y - BORDER_M - rr * CELL_M - CELL_M / 2 ct = self.cell_types.get((rr,cc), {}) if ct.get("type") == "flat": z_plat = 0 else: ref_cm = ct.get("ref_plane_cm", DEFAULT_REF_PLANE_CM) sh = ct.get("step_h", 0) ns = ct.get("num_steps", 0) concave = ct["type"] == "concave" z_plat = (ref_cm - ns * sh * 100) / 100.0 if concave else (ref_cm + ns * sh * 100) / 100.0 lines.append(f" ({rr},{cc}) x={cx:+.1f} y={cy:+.1f} {ct['type']} " f"z_plat={z_plat*100:.0f}cm") lines.append("") lines.append("# === Pyramid tread details ===") for r in range(self.rows): for c in range(self.cols): ct = self.cell_types.get((r,c), {"type": "flat"}) if ct["type"] == "flat": continue cx = -half_x + BORDER_M + c * CELL_M + CELL_M / 2 cy = half_y - BORDER_M - r * CELL_M - CELL_M / 2 sh = ct.get("step_h", DEFAULT_STEP_H) sd = ct.get("step_d", DEFAULT_STEP_D) ns = ct.get("num_steps", DEFAULT_NUM_STEPS) concave = ct["type"] == "concave" ref_cm = ct.get("ref_plane_cm", DEFAULT_REF_PLANE_CM) ref_z = ref_cm / 100.0 h_vs = int(sh / VS) p2 = PLATFORM_PX // 2 d_px = int(sd / HS) plat_z = (ref_cm - ns * sh * 100) / 100.0 if concave else (ref_cm + ns * sh * 100) / 100.0 lines.append(f" ({r},{c}) {ct['type']} center=({cx:+.1f}, {cy:+.1f}) " f"ref_plane={ref_z*100:.0f}cm platform={plat_z*100:.0f}cm " f"step_h={sh*100:.0f}cm tread={sd*100:.0f}cm steps={ns}") for i in range(ns + 1): half_m = (p2 + (ns - i) * d_px) * HS if concave: z = ref_z - (h_vs * i) * VS else: z = ref_z + (h_vs * i) * VS ring_type = "platform" if i == ns else "ring" lines.append(f" {ring_type} {i}: z={z*100:5.0f}cm " f"half={half_m:.2f}m " f"x=[{cx-half_m:+.1f},{cx+half_m:+.1f}] " f"y=[{cy-half_m:+.1f},{cy+half_m:+.1f}]") lines.append("") lines.append(f"# Training: DREAMWAQ_TERRAIN=flat_stairs " f"uv run scripts/train_dreamwaq_rsl.py --level N") info = "\n".join(lines) print(info) messagebox.showinfo("Exported", f"{out_path}\n\n{info}") if __name__ == "__main__": root = tk.Tk() root.geometry("900x550") TerrainEditor(root) root.mainloop()