mycopunk-swag/cli/mycopunk/design.py

388 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Design management commands for mycopunk CLI.
"""
import os
from pathlib import Path
from typing import Optional
from datetime import date
import yaml
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
console = Console()
# Project root detection
def get_project_root() -> Path:
"""Find the project root (directory containing designs/)."""
current = Path.cwd()
while current != current.parent:
if (current / "designs").is_dir():
return current
current = current.parent
# Default to cwd if not found
return Path.cwd()
DESIGNS_DIR = get_project_root() / "designs"
TEMPLATES_DIR = get_project_root() / "templates"
# Default metadata template
METADATA_TEMPLATE = """name: "{name}"
slug: {slug}
description: "Description of your design"
tags: [mycopunk, mycelium]
created: {date}
author: jeff
source:
file: {slug}.svg
format: svg
dimensions:
width: {width}
height: {height}
dpi: 300
color_profile: sRGB
products:
{products}
status: draft
"""
# Product templates by type
PRODUCT_TEMPLATES = {
"sticker-3x3": {
"width": 900,
"height": 900,
"products": """ - type: sticker
provider: prodigi
sku: GLOBAL-STI-KIS-3X3
variants: [matte, gloss]
retail_price: 3.50""",
},
"sticker-4x4": {
"width": 1200,
"height": 1200,
"products": """ - type: sticker
provider: prodigi
sku: GLOBAL-STI-KIS-4X4
variants: [matte, gloss]
retail_price: 4.50""",
},
"sticker-6x6": {
"width": 1800,
"height": 1800,
"products": """ - type: sticker
provider: prodigi
sku: GLOBAL-STI-KIS-6X6
variants: [matte]
retail_price: 6.00""",
},
"tshirt-front": {
"width": 3600,
"height": 4800,
"products": """ - type: tshirt
provider: printful
sku: 71
placement: front
colors: [black, white, heather_charcoal]
sizes: [S, M, L, XL, 2XL]
retail_price: 28.00""",
},
"tshirt-chest": {
"width": 1200,
"height": 1200,
"products": """ - type: tshirt
provider: printful
sku: 71
placement: chest
colors: [black, white]
sizes: [S, M, L, XL, 2XL]
retail_price: 25.00""",
},
"print-8x10": {
"width": 2400,
"height": 3000,
"products": """ - type: print
provider: prodigi
sku: GLOBAL-FAP-8X10
variants: [matte, lustre]
retail_price: 15.00""",
},
}
def create_design(path: str, template: str) -> None:
"""Create a new design scaffold."""
# Parse path
parts = path.split("/")
if len(parts) < 2:
console.print("[red]Error: Path must be category/design-name (e.g., stickers/my-design)[/red]")
raise SystemExit(1)
category = parts[0]
slug = parts[-1]
name = slug.replace("-", " ").title()
# Validate template
if template not in PRODUCT_TEMPLATES:
console.print(f"[red]Error: Unknown template '{template}'[/red]")
console.print(f"Available templates: {', '.join(PRODUCT_TEMPLATES.keys())}")
raise SystemExit(1)
template_config = PRODUCT_TEMPLATES[template]
# Create directory structure
design_dir = DESIGNS_DIR / category / slug
exports_dir = design_dir / "exports" / "300dpi"
mockups_dir = design_dir / "exports" / "mockups"
if design_dir.exists():
console.print(f"[red]Error: Design already exists at {design_dir}[/red]")
raise SystemExit(1)
design_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
mockups_dir.mkdir(parents=True, exist_ok=True)
# Create metadata.yaml
metadata_content = METADATA_TEMPLATE.format(
name=name,
slug=slug,
date=date.today().isoformat(),
width=template_config["width"],
height=template_config["height"],
products=template_config["products"],
)
metadata_path = design_dir / "metadata.yaml"
metadata_path.write_text(metadata_content)
# Create placeholder SVG
svg_path = design_dir / f"{slug}.svg"
svg_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="{template_config["width"]}"
height="{template_config["height"]}"
viewBox="0 0 {template_config["width"]} {template_config["height"]}">
<!--
Mycopunk Design: {name}
Template: {template}
Canvas: {template_config["width"]}x{template_config["height"]} px @ 300 DPI
Replace this content with your design.
-->
<rect width="100%" height="100%" fill="none" stroke="#ccc" stroke-width="2" stroke-dasharray="10,10"/>
<text x="50%" y="50%" text-anchor="middle" fill="#999" font-family="sans-serif" font-size="48">
{name}
</text>
</svg>'''
svg_path.write_text(svg_content)
# Create .gitkeep in exports
(exports_dir / ".gitkeep").touch()
(mockups_dir / ".gitkeep").touch()
console.print(Panel(
f"""[green]✓ Created design scaffold[/green]
[bold]Location:[/bold] {design_dir}
[bold]Template:[/bold] {template}
[bold]Canvas:[/bold] {template_config["width"]}×{template_config["height"]} px
[bold]Files created:[/bold]
{slug}.svg - Edit this in Inkscape
• metadata.yaml - Configure products
• exports/ - Generated files go here
[bold]Next steps:[/bold]
1. Open {svg_path} in Inkscape
2. Create your design
3. Edit metadata.yaml to configure products
4. Run: mycopunk design export {path}""",
title="🍄 New Design",
))
def list_designs(status: Optional[str] = None, design_type: Optional[str] = None) -> None:
"""List all designs in the repository."""
table = Table(title="🍄 Mycopunk Designs")
table.add_column("Path", style="cyan")
table.add_column("Name", style="green")
table.add_column("Status", style="yellow")
table.add_column("Products", style="blue")
table.add_column("Created", style="dim")
designs_found = 0
for category_dir in DESIGNS_DIR.iterdir():
if not category_dir.is_dir():
continue
# Filter by type
if design_type and category_dir.name != design_type:
continue
for design_dir in category_dir.iterdir():
if not design_dir.is_dir():
continue
metadata_path = design_dir / "metadata.yaml"
if not metadata_path.exists():
continue
try:
with open(metadata_path) as f:
metadata = yaml.safe_load(f)
except Exception:
continue
# Filter by status
design_status = metadata.get("status", "unknown")
if status and design_status != status:
continue
# Count products
products = metadata.get("products", [])
product_count = len(products)
designs_found += 1
created = metadata.get("created", "unknown")
# Convert date objects to string for Rich table
if hasattr(created, "isoformat"):
created = created.isoformat()
table.add_row(
f"{category_dir.name}/{design_dir.name}",
metadata.get("name", design_dir.name),
design_status,
str(product_count),
str(created),
)
if designs_found == 0:
console.print("[yellow]No designs found.[/yellow]")
console.print("Create a new design with: mycopunk design new stickers/my-design")
else:
console.print(table)
console.print(f"\n[dim]Total: {designs_found} designs[/dim]")
def validate_design(path: str, strict: bool = False) -> None:
"""Validate a design meets POD requirements."""
from PIL import Image
design_dir = DESIGNS_DIR / path
if not design_dir.exists():
console.print(f"[red]Error: Design not found at {design_dir}[/red]")
raise SystemExit(1)
metadata_path = design_dir / "metadata.yaml"
if not metadata_path.exists():
console.print("[red]Error: metadata.yaml not found[/red]")
raise SystemExit(1)
with open(metadata_path) as f:
metadata = yaml.safe_load(f)
console.print(f"\n[bold]Validating: {metadata.get('name', path)}[/bold]\n")
errors = []
warnings = []
passed = []
# Check required metadata fields
required_fields = ["name", "slug", "source", "products", "status"]
for field in required_fields:
if field not in metadata:
errors.append(f"Missing required field: {field}")
else:
passed.append(f"Metadata field '{field}' present")
# Check source file exists
source_info = metadata.get("source", {})
source_file = source_info.get("file")
if source_file:
source_path = design_dir / source_file
if source_path.exists():
passed.append(f"Source file exists: {source_file}")
# If PNG exists, check dimensions
export_png = design_dir / "exports" / "300dpi" / source_file.replace(".svg", ".png")
if export_png.exists():
try:
with Image.open(export_png) as img:
width, height = img.size
expected_w = source_info.get("dimensions", {}).get("width")
expected_h = source_info.get("dimensions", {}).get("height")
if expected_w and expected_h:
if width == expected_w and height == expected_h:
passed.append(f"Dimensions correct: {width}×{height}")
else:
errors.append(f"Dimensions mismatch: got {width}×{height}, expected {expected_w}×{expected_h}")
# Check DPI (approximate from metadata)
if img.info.get("dpi"):
dpi = img.info["dpi"][0]
if dpi >= 300:
passed.append(f"Resolution: {dpi} DPI")
else:
warnings.append(f"Low resolution: {dpi} DPI (recommended: 300+)")
# Check for transparency
if img.mode == "RGBA":
passed.append("Transparency: Supported")
else:
warnings.append(f"No transparency: Image mode is {img.mode}")
except Exception as e:
warnings.append(f"Could not analyze PNG: {e}")
else:
warnings.append("No exported PNG found - run 'mycopunk design export' first")
else:
errors.append(f"Source file missing: {source_file}")
else:
errors.append("No source file specified in metadata")
# Check products configuration
products = metadata.get("products", [])
if not products:
warnings.append("No products configured")
else:
for i, product in enumerate(products):
if "type" not in product:
errors.append(f"Product {i+1}: Missing 'type' field")
if "provider" not in product:
errors.append(f"Product {i+1}: Missing 'provider' field")
if "retail_price" not in product:
warnings.append(f"Product {i+1}: No retail price set")
passed.append(f"Products configured: {len(products)}")
# Print results
for msg in passed:
console.print(f" [green]✓[/green] {msg}")
for msg in warnings:
console.print(f" [yellow]⚠[/yellow] {msg}")
for msg in errors:
console.print(f" [red]✗[/red] {msg}")
console.print()
if errors:
console.print(f"[red]Validation failed with {len(errors)} error(s)[/red]")
raise SystemExit(1)
elif warnings and strict:
console.print(f"[yellow]Validation failed with {len(warnings)} warning(s) (strict mode)[/yellow]")
raise SystemExit(1)
elif warnings:
console.print(f"[yellow]Validation passed with {len(warnings)} warning(s)[/yellow]")
else:
console.print("[green]✓ Validation passed[/green]")