384 lines
12 KiB
Python
384 lines
12 KiB
Python
"""
|
||
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
|
||
table.add_row(
|
||
f"{category_dir.name}/{design_dir.name}",
|
||
metadata.get("name", design_dir.name),
|
||
design_status,
|
||
str(product_count),
|
||
metadata.get("created", "unknown"),
|
||
)
|
||
|
||
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]")
|