File size: 10,209 Bytes
a54a5ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299ba9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a54a5ca
 
 
 
299ba9b
 
 
a54a5ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
"""
Workspace management for Open3DForge.

Single-user pattern: one persistent workspace folder.
No session IDs, no multi-tenancy.

Layout:
    workspace/
        current/    -- active work, overwritten per generation
        exports/    -- finished asset zips, kept indefinitely
        presets/    -- saved parameter configurations (JSON)
        history/    -- thumbnails + metadata of past assets
"""

from __future__ import annotations

import json
import os
import shutil
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

ROOT = Path(__file__).resolve().parent.parent
WORKSPACE = ROOT / "workspace"
CURRENT = WORKSPACE / "current"
EXPORTS = WORKSPACE / "exports"
PRESETS = WORKSPACE / "presets"
HISTORY = WORKSPACE / "history"

# Subdirectories of `current/` created on each generation
CURRENT_TEXTURES = CURRENT / "textures"
CURRENT_LODS = CURRENT / "lods"


def ensure_dirs() -> None:
    """Create all workspace directories if missing. Safe to call repeatedly."""
    for p in (WORKSPACE, CURRENT, EXPORTS, PRESETS, HISTORY,
              CURRENT_TEXTURES, CURRENT_LODS):
        p.mkdir(parents=True, exist_ok=True)


def reset_current() -> None:
    """Clear `current/` for a fresh asset. Called at the start of generation."""
    if CURRENT.exists():
        shutil.rmtree(CURRENT)
    CURRENT.mkdir(parents=True, exist_ok=True)
    CURRENT_TEXTURES.mkdir(parents=True, exist_ok=True)
    CURRENT_LODS.mkdir(parents=True, exist_ok=True)


# ---------------------------------------------------------------------------
# Asset state (in-memory representation of what's in `current/`)
# ---------------------------------------------------------------------------

@dataclass
class AssetState:
    """Tracks what files exist in `current/` and the pipeline progress.

    Updated by each stage as it completes. Used by the UI to enable/disable
    buttons and show status indicators.
    """

    # Input
    input_images: list[Path] = field(default_factory=list)

    # Stage 1 outputs
    high_poly_glb: Path | None = None         # raw TRELLIS.2 output, kept for baking
    raw_gen_glb: Path | None = None           # decimated base from generation

    # Stage 2 outputs
    repaired_glb: Path | None = None
    cleaned_glb: Path | None = None
    low_poly_glb: Path | None = None          # post-decimation working mesh
    unwrapped_glb: Path | None = None         # has UV coordinates
    final_glb: Path | None = None             # all post-processing done

    # Textures (Stage 2 baking)
    albedo_png: Path | None = None
    normal_gl_png: Path | None = None
    normal_dx_png: Path | None = None
    roughness_png: Path | None = None
    metallic_png: Path | None = None
    ao_png: Path | None = None
    orm_png: Path | None = None               # UE5-packed AO/Rough/Metal
    metallic_smoothness_png: Path | None = None  # Unity-packed

    # LODs
    lod_glbs: list[Path] = field(default_factory=list)

    # Collision
    collision_glb: Path | None = None

    # Rigging (Stage 3)
    rigged_glb: Path | None = None
    rigged_fbx: Path | None = None

    # Metadata
    asset_name: str = "untitled"
    generated_at: float = field(default_factory=time.time)
    model_used: str = ""                      # "TRELLIS.2" or "Hunyuan3D-2"
    face_count: int = 0
    vertex_count: int = 0

    def to_dict(self) -> dict[str, Any]:
        """Serialise for status display / debug."""
        out: dict[str, Any] = {}
        for k, v in self.__dict__.items():
            if isinstance(v, Path):
                out[k] = str(v) if v else None
            elif isinstance(v, list):
                out[k] = [str(p) for p in v]
            else:
                out[k] = v
        return out


# ---------------------------------------------------------------------------
# Filesystem-based state persistence
#
# ZeroGPU runs @spaces.GPU functions in a forked subprocess.  Any writes to
# module-level variables (like _state) happen in the subprocess and are
# invisible to the parent Gradio process.  To survive the process boundary we
# write state to a JSON file inside CURRENT/ and always read back from there.
# ---------------------------------------------------------------------------

_META_FILE = CURRENT / ".meta.json"

# File names that map to AssetState path attributes (order = preference)
_PATH_ATTRS: list[tuple[str, Path]] = [
    ("rigged_fbx",     CURRENT / "rigged.fbx"),
    ("rigged_glb",     CURRENT / "rigged.glb"),
    ("final_glb",      CURRENT / "scaled.glb"),
    ("final_glb",      CURRENT / "pivoted.glb"),
    ("unwrapped_glb",  CURRENT / "unwrapped.glb"),
    ("low_poly_glb",   CURRENT / "low_poly.glb"),
    ("cleaned_glb",    CURRENT / "cleaned.glb"),
    ("repaired_glb",   CURRENT / "repaired.glb"),
    ("raw_gen_glb",    CURRENT / "raw_gen.glb"),
    ("high_poly_glb",  CURRENT / "high_poly.glb"),
    ("normal_dx_png",  CURRENT / "textures" / "normal_dx.png"),
    ("normal_gl_png",  CURRENT / "textures" / "normal_gl.png"),
    ("albedo_png",     CURRENT / "textures" / "albedo.png"),
    ("roughness_png",  CURRENT / "textures" / "roughness.png"),
    ("metallic_png",   CURRENT / "textures" / "metallic.png"),
    ("ao_png",         CURRENT / "textures" / "ao.png"),
    ("orm_png",        CURRENT / "textures" / "orm.png"),
    ("collision_glb",  CURRENT / "collision.glb"),
]


def _build_state_from_disk() -> AssetState:
    """Reconstruct AssetState by scanning CURRENT/ and reading .meta.json."""
    state = AssetState()

    # Read persisted metadata (face count, model name, etc.)
    if _META_FILE.exists():
        try:
            meta = json.loads(_META_FILE.read_text())
            state.asset_name  = meta.get("asset_name", "untitled")
            state.model_used  = meta.get("model_used", "")
            state.face_count  = meta.get("face_count", 0)
            state.vertex_count = meta.get("vertex_count", 0)
        except Exception:
            pass

    # Populate path attributes from filesystem
    seen_attrs: set[str] = set()
    for attr, path in _PATH_ATTRS:
        if path.exists() and attr not in seen_attrs:
            setattr(state, attr, path)
            seen_attrs.add(attr)

    # LODs
    lod_dir = CURRENT / "lods"
    if lod_dir.exists():
        state.lod_glbs = sorted(lod_dir.glob("LOD*.glb"))

    return state


def flush_meta(state: AssetState) -> None:
    """Write lightweight metadata to disk so the parent process can read it."""
    try:
        _META_FILE.write_text(json.dumps({
            "asset_name":   state.asset_name,
            "model_used":   state.model_used,
            "face_count":   state.face_count,
            "vertex_count": state.vertex_count,
        }))
    except Exception:
        pass


# Module-level singleton — kept for in-process use (e.g. stage2 steps that
# run in the same process as Gradio).  Always prefer get_state() which syncs
# from disk first, making it safe across the ZeroGPU process boundary.
_state: AssetState = AssetState()


def get_state() -> AssetState:
    """Return current state, rebuilding from disk to handle ZeroGPU isolation."""
    global _state
    _state = _build_state_from_disk()
    return _state


def reset_state() -> AssetState:
    """Replace the global state with a fresh one. Returns the new state."""
    global _state
    _state = AssetState()
    return _state


# ---------------------------------------------------------------------------
# Presets (JSON-on-disk, loaded as dicts)
# ---------------------------------------------------------------------------

def list_presets() -> list[str]:
    """Return preset names (filenames without .json), sorted."""
    if not PRESETS.exists():
        return []
    return sorted(p.stem for p in PRESETS.glob("*.json"))


def load_preset(name: str) -> dict[str, Any]:
    """Load a preset by name. Raises FileNotFoundError if missing."""
    path = PRESETS / f"{name}.json"
    if not path.exists():
        raise FileNotFoundError(f"Preset not found: {name}")
    with path.open("r", encoding="utf-8") as f:
        return json.load(f)


def save_preset(name: str, config: dict[str, Any]) -> Path:
    """Save a preset as JSON. Overwrites if exists."""
    safe_name = _sanitize_filename(name)
    path = PRESETS / f"{safe_name}.json"
    with path.open("w", encoding="utf-8") as f:
        json.dump(config, f, indent=2, sort_keys=True)
    return path


def delete_preset(name: str) -> bool:
    """Delete a preset. Returns True if deleted, False if it didn't exist."""
    path = PRESETS / f"{name}.json"
    if path.exists():
        path.unlink()
        return True
    return False


def _sanitize_filename(name: str) -> str:
    """Strip path separators and unsafe chars from a filename stem."""
    return "".join(c for c in name if c.isalnum() or c in "_-").strip("_-") or "preset"


# ---------------------------------------------------------------------------
# Workspace stats (for UI status bar)
# ---------------------------------------------------------------------------

def workspace_size_mb() -> float:
    """Total size of the workspace in MB."""
    total = 0
    if WORKSPACE.exists():
        for path in WORKSPACE.rglob("*"):
            if path.is_file():
                total += path.stat().st_size
    return total / (1024 * 1024)


def current_size_mb() -> float:
    """Size of `current/` only (active work)."""
    total = 0
    if CURRENT.exists():
        for path in CURRENT.rglob("*"):
            if path.is_file():
                total += path.stat().st_size
    return total / (1024 * 1024)


def export_count() -> int:
    """How many exported zips exist."""
    if not EXPORTS.exists():
        return 0
    return len(list(EXPORTS.glob("*.zip")))


# ---------------------------------------------------------------------------
# Module init
# ---------------------------------------------------------------------------

# Auto-create folders on import so the app never crashes on a fresh checkout
ensure_dirs()