tomiconic commited on
Commit
52fd31f
·
verified ·
1 Parent(s): 7dfd6a8

Upload 9 files

Browse files
Files changed (5) hide show
  1. README.md +26 -94
  2. app.py +123 -38
  3. generator.py +206 -96
  4. model_runtime.py +54 -0
  5. requirements.txt +1 -0
README.md CHANGED
@@ -1,117 +1,45 @@
1
  ---
2
  title: Particle Blueprint 3D
3
- emoji: 🧩
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
 
7
  app_file: app.py
8
  pinned: false
9
- license: mit
10
  ---
11
 
12
  # Particle Blueprint 3D
13
 
14
- A Hugging Face Space for a scaffold-first 3D workflow:
15
 
16
- **prompt structured particle blueprint → reconstructed mesh → GLB**
17
 
18
- This version is tuned for **mobile iPhone use first** and adds an optional **local Hugging Face model** for better prompt-to-shape planning.
 
 
 
 
 
 
19
 
20
- ## What changed
21
 
22
- - Mobile-first single-column UX with larger tap targets.
23
- - Sticky action area on narrow screens.
24
- - Prompt presets for fast one-thumb use.
25
- - Tabs instead of side-by-side viewers, which works better on phones.
26
- - Optional local model planner before blueprint generation.
27
 
28
- ## Why the local model is used only for planning
 
 
 
29
 
30
- The local model does **not** try to directly generate a final GLB.
31
- It is used to convert a messy natural-language prompt into a cleaner structured spec:
32
 
33
- - object type
34
- - scale
35
- - hull style
36
- - engine count
37
- - wing span
38
- - cargo ratio
39
- - cockpit ratio
40
- - fin height
41
- - landing gear
42
- - asymmetry
43
-
44
- That structured spec then drives the scaffold and meshing pipeline.
45
- This is much more realistic than pretending a small local model can do full end-to-end 3D generation well.
46
-
47
- ## Recommended local models
48
-
49
- ### Best default
50
-
51
- - `Qwen/Qwen2.5-1.5B-Instruct`
52
-
53
- Use this as the main local planner. It is the stronger option for extracting useful structure from prompts.
54
-
55
- ### Lighter fallback
56
-
57
- - `HuggingFaceTB/SmolLM2-1.7B-Instruct`
58
-
59
- Use this if you want a smaller-feeling fallback or if Qwen has issues in your runtime.
60
-
61
- ## Current pipeline
62
-
63
- 1. Parse the prompt with either:
64
- - fast heuristic parser
65
- - local Hugging Face model planner
66
- 2. Generate a structured particle blueprint.
67
- 3. Convert the blueprint into a voxel field.
68
- 4. Reconstruct a triangle mesh.
69
- 5. Export both `.ply` and `.glb`.
70
-
71
- ## ZeroGPU strategy
72
-
73
- Use the local model only when needed.
74
- The LLM generation function is isolated so it can be decorated for ZeroGPU use without forcing the whole app onto GPU every time.
75
-
76
- ## Local development
77
-
78
- ```bash
79
- pip install -r requirements.txt
80
- python app.py
81
- ```
82
-
83
- ## Deploy to Hugging Face
84
-
85
- Create a **Gradio** Space and push these files.
86
-
87
- ```bash
88
- git init
89
- git add .
90
- git commit -m "Mobile UX + local HF planner"
91
- git branch -M main
92
- git remote add origin https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
93
- git push -u origin main
94
- ```
95
-
96
- ## Notes
97
-
98
- This is still the right honest framing:
99
-
100
- - AI plans the shape
101
- - the app builds the scaffold
102
- - the scaffold becomes the mesh
103
-
104
- That middle layer is the product.
105
-
106
-
107
- ## Flat repo layout
108
-
109
- This build is flattened so **every file lives in the repo root**.
110
-
111
- Upload these files directly into the Hugging Face Space root:
112
 
113
  - `app.py`
114
  - `generator.py`
 
115
  - `llm_parser.py`
116
  - `parser.py`
117
  - `requirements.txt`
@@ -119,4 +47,8 @@ Upload these files directly into the Hugging Face Space root:
119
  - `README.md`
120
  - `LICENSE`
121
 
122
- No `src/` folder is needed.
 
 
 
 
 
1
  ---
2
  title: Particle Blueprint 3D
3
+ emoji: 🚀
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 5.24.0
8
  app_file: app.py
9
  pinned: false
10
+ python_version: 3.10
11
  ---
12
 
13
  # Particle Blueprint 3D
14
 
15
+ Mobile-first Hugging Face Space for **prompt live particle blueprint → inspect → mesh**.
16
 
17
+ ## What this version does
18
 
19
+ - works with a **flat repo root** so you can upload files directly without creating folders
20
+ - uses a **two-step workflow**:
21
+ 1. generate the particle blueprint live in stages
22
+ 2. inspect it in the viewport
23
+ 3. tap **Make mesh from blueprint** when happy
24
+ - supports a local planner model for prompt-to-structure parsing
25
+ - caches **`tencent/Hunyuan3D-Omni`** during the mesh step so the Space is prepared for deeper target-model integration later
26
 
27
+ ## Current reality
28
 
29
+ This build gives you the UX and the working two-phase geometry pipeline now.
 
 
 
 
30
 
31
+ - **Blueprint step** is real and working
32
+ - **Mesh step** is real and working
33
+ - **Hunyuan3D-Omni cache prep** is wired in
34
+ - the final mesh generation still uses the app's local voxel mesher for reliability
35
 
36
+ That means the Space already behaves the way you want for mobile use, while still preparing the official target model repo inside the Space filesystem.
 
37
 
38
+ ## Files to upload to the Space root
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  - `app.py`
41
  - `generator.py`
42
+ - `model_runtime.py`
43
  - `llm_parser.py`
44
  - `parser.py`
45
  - `requirements.txt`
 
47
  - `README.md`
48
  - `LICENSE`
49
 
50
+ ## Notes
51
+
52
+ - if the Space restarts, model cache may need to be fetched again unless you use persistent storage
53
+ - first run with local planner models can take longer because weights need to download
54
+ - the 3D viewers are tuned for touch on iPhone
app.py CHANGED
@@ -2,15 +2,16 @@ from __future__ import annotations
2
 
3
  import gradio as gr
4
 
5
- from generator import run_pipeline
6
  from llm_parser import DEFAULT_LOCAL_MODEL, MODEL_PRESETS
 
7
 
8
 
9
  TITLE = "Particle Blueprint 3D"
10
- TAGLINE = "Mobile-first promptparticle scaffold → mesh GLB"
11
  DESCRIPTION = (
12
- "Built for iPhone use first: a compact prompt flow, one-tap presets, low-friction controls, "
13
- "and an optional local Hugging Face model that plans the blueprint before meshing."
14
  )
15
 
16
  PROMPT_PRESETS = {
@@ -23,60 +24,115 @@ CSS = """
23
  footer {display:none !important}
24
  .gradio-container {max-width: 1120px !important; margin: 0 auto; padding: 0 12px 28px 12px !important}
25
  #app-shell {gap: 14px}
26
- .hero-card, .panel-card {border: 1px solid rgba(255,255,255,0.08); border-radius: 22px; padding: 14px 16px; background: rgba(255,255,255,0.03)}
27
  .hero-title {font-size: 1.5rem; font-weight: 800; margin: 0 0 6px 0}
28
  .hero-sub {opacity: 0.88; margin: 0}
 
 
 
29
  .cta-row {gap: 10px}
30
- .cta-row button {min-height: 50px !important; border-radius: 16px !important; font-size: 1rem !important}
31
  .preset-row {display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px}
32
  .preset-row button {min-height: 46px !important; border-radius: 16px !important}
33
  .model3d-wrap {border-radius: 20px; overflow: hidden}
34
  .mobile-note {font-size: 0.92rem; opacity: 0.82}
 
 
35
  @media (max-width: 820px) {
36
  .gradio-container {padding: 0 10px 22px 10px !important}
37
- .hero-card, .panel-card {padding: 12px 12px}
38
  .hero-title {font-size: 1.28rem}
39
- .preset-row {grid-template-columns: 1fr}
40
- .cta-row {position: sticky; bottom: 10px; z-index: 10; background: rgba(15,18,24,0.86); backdrop-filter: blur(12px); padding: 8px; border: 1px solid rgba(255,255,255,0.08); border-radius: 18px}
41
  }
42
  """
43
 
44
 
45
- def generate_asset(prompt: str, parser_mode: str, model_choice: str, detail: int, voxel_pitch: float):
 
 
 
 
46
  prompt = (prompt or "").strip()
47
  if not prompt:
48
  raise gr.Error("Enter a prompt first.")
49
 
50
  model_id = MODEL_PRESETS.get(model_choice, model_choice or DEFAULT_LOCAL_MODEL)
51
- artifacts = run_pipeline(
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  prompt=prompt,
53
  detail=detail,
54
- voxel_pitch=voxel_pitch,
55
  parser_mode=parser_mode,
56
  model_id=model_id,
57
- )
58
- status = (
59
- f"Built {artifacts.summary['point_count']} scaffold points and "
60
- f"{artifacts.summary['face_count']} mesh faces using {artifacts.summary['parser_backend']}."
61
- )
62
- return artifacts.ply_path, artifacts.glb_path, artifacts.summary, artifacts.ply_path, artifacts.glb_path, status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
 
65
  with gr.Blocks(theme=gr.themes.Soft(), css=CSS, title=TITLE, fill_width=True) as demo:
 
 
66
  with gr.Column(elem_id="app-shell"):
67
  gr.HTML(
68
  f"""
69
  <div class='hero-card'>
70
  <div class='hero-title'>{TITLE}</div>
71
  <p class='hero-sub'><strong>{TAGLINE}</strong><br>{DESCRIPTION}</p>
72
- <p class='mobile-note'>Best on mobile for ships, drones, cargo gear and other hard-surface forms.</p>
 
 
 
 
 
 
73
  </div>
74
  """
75
  )
76
 
77
  with gr.Column(elem_classes=["panel-card"]):
78
  prompt = gr.Textbox(
79
- label="Describe the object",
80
  lines=4,
81
  max_lines=6,
82
  placeholder="Example: small cargo hauler with a boxy hull, rear ramp, cargo bay, 4 engines and landing gear",
@@ -88,27 +144,34 @@ with gr.Blocks(theme=gr.themes.Soft(), css=CSS, title=TITLE, fill_width=True) as
88
  preset_b = gr.Button("Compact fighter")
89
  preset_c = gr.Button("Industrial dropship")
90
 
91
- with gr.Accordion("Model and quality", open=False):
92
  parser_mode = gr.Radio(
93
- choices=[("Fast heuristic", "heuristic"), ("Local HF model", "local")],
94
  value="heuristic",
95
  label="Prompt planner",
96
- info="Use the local model when you want better shape planning from messy prompts.",
97
  )
98
  model_choice = gr.Dropdown(
99
  choices=list(MODEL_PRESETS.keys()),
100
  value="Qwen 2.5 1.5B",
101
- label="Local model",
102
- info="Qwen is the stronger planner. SmolLM2 is the lighter fallback.",
 
 
 
 
 
 
103
  )
104
- detail = gr.Slider(12, 32, value=18, step=2, label="Detail")
105
- voxel_pitch = gr.Slider(0.06, 0.12, value=0.09, step=0.005, label="Mesh density")
106
 
107
  with gr.Row(elem_classes=["cta-row"]):
108
- run_btn = gr.Button("Generate blueprint", variant="primary")
 
109
  clear_btn = gr.Button("Clear")
110
 
111
- status = gr.Markdown("Ready.")
 
 
112
 
113
  with gr.Tabs():
114
  with gr.TabItem("Blueprint"):
@@ -116,19 +179,25 @@ with gr.Blocks(theme=gr.themes.Soft(), css=CSS, title=TITLE, fill_width=True) as
116
  label="Particle blueprint (.ply)",
117
  display_mode="point_cloud",
118
  clear_color=(0.02, 0.02, 0.03, 1.0),
 
 
 
119
  elem_classes=["model3d-wrap"],
120
- height=520,
121
  )
122
  with gr.TabItem("Mesh"):
123
  mesh_view = gr.Model3D(
124
  label="Mesh preview (.glb)",
125
  display_mode="solid",
126
  clear_color=(0.02, 0.02, 0.03, 1.0),
 
 
 
127
  elem_classes=["model3d-wrap"],
128
- height=520,
129
  )
130
  with gr.TabItem("Summary and files"):
131
- summary = gr.JSON(label="Build summary")
132
  blueprint_file = gr.File(label="Download blueprint (.ply)")
133
  mesh_file = gr.File(label="Download mesh (.glb)")
134
 
@@ -136,15 +205,31 @@ with gr.Blocks(theme=gr.themes.Soft(), css=CSS, title=TITLE, fill_width=True) as
136
  preset_b.click(lambda: PROMPT_PRESETS["Compact fighter"], outputs=prompt)
137
  preset_c.click(lambda: PROMPT_PRESETS["Industrial dropship"], outputs=prompt)
138
 
139
- run_btn.click(
140
- fn=generate_asset,
141
- inputs=[prompt, parser_mode, model_choice, detail, voxel_pitch],
142
- outputs=[blueprint_view, mesh_view, summary, blueprint_file, mesh_file, status],
 
 
 
 
 
 
143
  )
144
 
145
  clear_btn.click(
146
- lambda: ("", None, None, None, None, None, "Ready."),
147
- outputs=[prompt, blueprint_view, mesh_view, summary, blueprint_file, mesh_file, status],
 
 
 
 
 
 
 
 
 
 
148
  )
149
 
150
 
 
2
 
3
  import gradio as gr
4
 
5
+ from generator import iter_blueprint_session, iter_meshify_session
6
  from llm_parser import DEFAULT_LOCAL_MODEL, MODEL_PRESETS
7
+ from model_runtime import TARGET_OMNI_MODEL
8
 
9
 
10
  TITLE = "Particle Blueprint 3D"
11
+ TAGLINE = "Describe generate blueprint live inspect in 3D → mesh when approved"
12
  DESCRIPTION = (
13
+ "Built for iPhone use first. Generate a structured particle blueprint with visible progress, "
14
+ "inspect it in the viewport, then turn the approved blueprint into a mesh."
15
  )
16
 
17
  PROMPT_PRESETS = {
 
24
  footer {display:none !important}
25
  .gradio-container {max-width: 1120px !important; margin: 0 auto; padding: 0 12px 28px 12px !important}
26
  #app-shell {gap: 14px}
27
+ .hero-card, .panel-card, .status-card {border: 1px solid rgba(255,255,255,0.08); border-radius: 22px; padding: 14px 16px; background: rgba(255,255,255,0.03)}
28
  .hero-title {font-size: 1.5rem; font-weight: 800; margin: 0 0 6px 0}
29
  .hero-sub {opacity: 0.88; margin: 0}
30
+ .flow-grid {display:grid; grid-template-columns: 1fr 1fr; gap: 10px}
31
+ .flow-chip {border:1px solid rgba(255,255,255,0.08); border-radius:16px; padding:12px; background:rgba(255,255,255,0.02)}
32
+ .flow-chip strong {display:block; margin-bottom:4px}
33
  .cta-row {gap: 10px}
34
+ .cta-row button {min-height: 52px !important; border-radius: 16px !important; font-size: 1rem !important}
35
  .preset-row {display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px}
36
  .preset-row button {min-height: 46px !important; border-radius: 16px !important}
37
  .model3d-wrap {border-radius: 20px; overflow: hidden}
38
  .mobile-note {font-size: 0.92rem; opacity: 0.82}
39
+ .small-note {font-size:0.88rem; opacity:0.8}
40
+ #status-box p {margin:0}
41
  @media (max-width: 820px) {
42
  .gradio-container {padding: 0 10px 22px 10px !important}
43
+ .hero-card, .panel-card, .status-card {padding: 12px 12px}
44
  .hero-title {font-size: 1.28rem}
45
+ .preset-row, .flow-grid {grid-template-columns: 1fr}
46
+ .cta-row {position: sticky; bottom: 10px; z-index: 10; background: rgba(15,18,24,0.90); backdrop-filter: blur(12px); padding: 8px; border: 1px solid rgba(255,255,255,0.08); border-radius: 18px}
47
  }
48
  """
49
 
50
 
51
+ def _status_md(text: str) -> str:
52
+ return f"**Status**\n\n{text}"
53
+
54
+
55
+ def stream_blueprint(prompt: str, parser_mode: str, model_choice: str, detail: int):
56
  prompt = (prompt or "").strip()
57
  if not prompt:
58
  raise gr.Error("Enter a prompt first.")
59
 
60
  model_id = MODEL_PRESETS.get(model_choice, model_choice or DEFAULT_LOCAL_MODEL)
61
+ latest_summary = None
62
+ latest_path = None
63
+ latest_state = None
64
+
65
+ yield (
66
+ _status_md("Starting blueprint generation…"),
67
+ None,
68
+ None,
69
+ None,
70
+ None,
71
+ gr.update(interactive=False),
72
+ )
73
+
74
+ for update in iter_blueprint_session(
75
  prompt=prompt,
76
  detail=detail,
 
77
  parser_mode=parser_mode,
78
  model_id=model_id,
79
+ ):
80
+ latest_summary = update.get("summary", latest_summary)
81
+ latest_path = update.get("blueprint_path", latest_path)
82
+ latest_state = update.get("state", latest_state)
83
+ status_text = update.get("status", "Working…")
84
+ yield (
85
+ _status_md(status_text),
86
+ latest_path,
87
+ latest_summary,
88
+ latest_path,
89
+ latest_state,
90
+ gr.update(interactive=bool(latest_state)),
91
+ )
92
+
93
+
94
+ def stream_mesh(state: dict, voxel_pitch: float, warm_target_model: bool):
95
+ if not state:
96
+ raise gr.Error("Generate a blueprint first.")
97
+
98
+ latest_summary = None
99
+ latest_mesh_path = None
100
+ yield _status_md("Starting mesh generation…"), None, None, None
101
+
102
+ for update in iter_meshify_session(
103
+ state=state,
104
+ voxel_pitch=voxel_pitch,
105
+ use_target_model_cache=warm_target_model,
106
+ ):
107
+ latest_summary = update.get("summary", latest_summary)
108
+ latest_mesh_path = update.get("mesh_path", latest_mesh_path)
109
+ status_text = update.get("status", "Meshing…")
110
+ yield _status_md(status_text), latest_mesh_path, latest_mesh_path, latest_summary
111
 
112
 
113
  with gr.Blocks(theme=gr.themes.Soft(), css=CSS, title=TITLE, fill_width=True) as demo:
114
+ session_state = gr.State(value=None)
115
+
116
  with gr.Column(elem_id="app-shell"):
117
  gr.HTML(
118
  f"""
119
  <div class='hero-card'>
120
  <div class='hero-title'>{TITLE}</div>
121
  <p class='hero-sub'><strong>{TAGLINE}</strong><br>{DESCRIPTION}</p>
122
+ <div class='flow-grid' style='margin-top:10px'>
123
+ <div class='flow-chip'><strong>1. Describe</strong>Tell it what to build.</div>
124
+ <div class='flow-chip'><strong>2. Watch blueprint form</strong>Hull, cockpit, cargo, engines and extras appear in stages.</div>
125
+ <div class='flow-chip'><strong>3. Inspect in 3D</strong>Rotate, pan and zoom the particle blueprint.</div>
126
+ <div class='flow-chip'><strong>4. Make mesh</strong>When it looks right, turn it into a GLB.</div>
127
+ </div>
128
+ <p class='mobile-note'>Target model cache: <strong>{TARGET_OMNI_MODEL}</strong></p>
129
  </div>
130
  """
131
  )
132
 
133
  with gr.Column(elem_classes=["panel-card"]):
134
  prompt = gr.Textbox(
135
+ label="Describe the model",
136
  lines=4,
137
  max_lines=6,
138
  placeholder="Example: small cargo hauler with a boxy hull, rear ramp, cargo bay, 4 engines and landing gear",
 
144
  preset_b = gr.Button("Compact fighter")
145
  preset_c = gr.Button("Industrial dropship")
146
 
147
+ with gr.Accordion("Planner and mesh settings", open=False):
148
  parser_mode = gr.Radio(
149
+ choices=[("Fast heuristic", "heuristic"), ("Local HF planner", "local")],
150
  value="heuristic",
151
  label="Prompt planner",
152
+ info="Use the local planner when the prompt is messy and you want better structure extraction.",
153
  )
154
  model_choice = gr.Dropdown(
155
  choices=list(MODEL_PRESETS.keys()),
156
  value="Qwen 2.5 1.5B",
157
+ label="Local planner model",
158
+ )
159
+ detail = gr.Slider(12, 34, value=20, step=2, label="Blueprint detail")
160
+ voxel_pitch = gr.Slider(0.055, 0.12, value=0.085, step=0.005, label="Mesh density")
161
+ warm_target_model = gr.Checkbox(
162
+ value=True,
163
+ label="Warm Hunyuan3D-Omni cache during mesh step",
164
+ info="This caches the target model repo in the Space for later deeper integration work.",
165
  )
 
 
166
 
167
  with gr.Row(elem_classes=["cta-row"]):
168
+ blueprint_btn = gr.Button("Generate blueprint", variant="primary")
169
+ mesh_btn = gr.Button("Make mesh from blueprint", interactive=False)
170
  clear_btn = gr.Button("Clear")
171
 
172
+ with gr.Column(elem_classes=["status-card"]):
173
+ status = gr.Markdown(_status_md("Ready."), elem_id="status-box")
174
+ gr.Markdown("<span class='small-note'>On iPhone: use one finger to orbit and two fingers to zoom/pan the model viewer.</span>")
175
 
176
  with gr.Tabs():
177
  with gr.TabItem("Blueprint"):
 
179
  label="Particle blueprint (.ply)",
180
  display_mode="point_cloud",
181
  clear_color=(0.02, 0.02, 0.03, 1.0),
182
+ camera_position=(35, 65, 6),
183
+ zoom_speed=1.15,
184
+ pan_speed=0.95,
185
  elem_classes=["model3d-wrap"],
186
+ height=540,
187
  )
188
  with gr.TabItem("Mesh"):
189
  mesh_view = gr.Model3D(
190
  label="Mesh preview (.glb)",
191
  display_mode="solid",
192
  clear_color=(0.02, 0.02, 0.03, 1.0),
193
+ camera_position=(35, 65, 6),
194
+ zoom_speed=1.15,
195
+ pan_speed=0.95,
196
  elem_classes=["model3d-wrap"],
197
+ height=540,
198
  )
199
  with gr.TabItem("Summary and files"):
200
+ summary = gr.JSON(label="Session summary")
201
  blueprint_file = gr.File(label="Download blueprint (.ply)")
202
  mesh_file = gr.File(label="Download mesh (.glb)")
203
 
 
205
  preset_b.click(lambda: PROMPT_PRESETS["Compact fighter"], outputs=prompt)
206
  preset_c.click(lambda: PROMPT_PRESETS["Industrial dropship"], outputs=prompt)
207
 
208
+ blueprint_btn.click(
209
+ fn=stream_blueprint,
210
+ inputs=[prompt, parser_mode, model_choice, detail],
211
+ outputs=[status, blueprint_view, summary, blueprint_file, session_state, mesh_btn],
212
+ )
213
+
214
+ mesh_btn.click(
215
+ fn=stream_mesh,
216
+ inputs=[session_state, voxel_pitch, warm_target_model],
217
+ outputs=[status, mesh_view, mesh_file, summary],
218
  )
219
 
220
  clear_btn.click(
221
+ lambda: (
222
+ "",
223
+ _status_md("Ready."),
224
+ None,
225
+ None,
226
+ None,
227
+ None,
228
+ None,
229
+ gr.update(interactive=False),
230
+ None,
231
+ ),
232
+ outputs=[prompt, status, blueprint_view, mesh_view, summary, blueprint_file, mesh_file, mesh_btn, session_state],
233
  )
234
 
235
 
generator.py CHANGED
@@ -4,7 +4,7 @@ import math
4
  import tempfile
5
  from dataclasses import dataclass
6
  from pathlib import Path
7
- from typing import Iterable
8
 
9
  import numpy as np
10
  import trimesh
@@ -12,6 +12,7 @@ from scipy import ndimage
12
  from skimage import measure
13
 
14
  from llm_parser import DEFAULT_LOCAL_MODEL, parse_prompt_with_local_llm
 
15
  from parser import PromptSpec, parse_prompt
16
 
17
 
@@ -108,108 +109,200 @@ def _sample_cylinder_surface(center, radius, length, axis: str, density: int, la
108
  return pts, normals, labels
109
 
110
 
111
- def build_particle_blueprint(
112
- prompt: str,
113
- detail: int = 24,
114
- parser_mode: str = "heuristic",
115
- model_id: str | None = None,
116
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray, PromptSpec, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  parser_mode = (parser_mode or "heuristic").strip().lower()
118
- parser_backend = "heuristic"
119
  if parser_mode.startswith("local"):
120
  spec = parse_prompt_with_local_llm(prompt, model_id=model_id or DEFAULT_LOCAL_MODEL)
121
- parser_backend = f"local_llm:{model_id or DEFAULT_LOCAL_MODEL}"
122
- else:
123
- spec = parse_prompt(prompt)
 
 
124
  scale = SCALE_FACTORS[spec.scale]
125
  density = max(6, detail)
126
 
127
- parts = []
128
- normals = []
129
- labels = []
130
-
131
  hull_len = 2.8 * scale
132
  hull_w = 1.2 * scale
133
  hull_h = 0.8 * scale
134
 
135
  if spec.hull_style == "rounded":
136
- p, n, l = _sample_ellipsoid_surface((0.0, 0.0, 0.0), (hull_len / 2, hull_w / 2, hull_h / 2), density, 0)
137
  elif spec.hull_style == "sleek":
138
  p1, n1, l1 = _sample_ellipsoid_surface((0.12 * scale, 0.0, 0.0), (hull_len / 2.3, hull_w / 2.8, hull_h / 2.6), density, 0)
139
- p2, n2, l2 = _sample_box_surface((-0.15 * scale, 0.0, -0.02 * scale), (hull_len * 0.52, hull_w * 0.5, hull_h * 0.55), density // 2, 0)
140
- p = np.vstack([p1, p2])
141
- n = np.vstack([n1, n2])
142
- l = np.concatenate([l1, l2])
143
  else:
144
- p, n, l = _sample_box_surface((0.0, 0.0, 0.0), (hull_len, hull_w, hull_h), density, 0)
145
- parts.append(p)
146
- normals.append(n)
147
- labels.append(l)
148
 
149
  cockpit_center = (hull_len / 2 - hull_len * spec.cockpit_ratio * 0.8, 0.0, hull_h * 0.14)
150
- cp, cn, cl = _sample_ellipsoid_surface(cockpit_center, (hull_len * spec.cockpit_ratio, hull_w * 0.22, hull_h * 0.24), density // 2, 1)
151
- parts.append(cp)
152
- normals.append(cn)
153
- labels.append(cl)
154
 
155
  if spec.cargo_ratio > 0.16:
156
  cargo_center = (-hull_len * 0.18, 0.0, -hull_h * 0.06)
157
  cargo_size = (hull_len * spec.cargo_ratio, hull_w * 0.76, hull_h * 0.6)
158
- pp, pn, pl = _sample_box_surface(cargo_center, cargo_size, density // 2, 2)
159
- parts.append(pp)
160
- normals.append(pn)
161
- labels.append(pl)
162
 
163
  if spec.wing_span > 0:
164
  wing_length = hull_len * 0.34
165
  wing_width = hull_w * 0.18
166
  wing_height = hull_h * 0.08
167
  yoff = hull_w * 0.45 + wing_width * 0.6
 
 
 
168
  for side in (-1, 1):
169
  wc = (-0.1 * scale, side * yoff, -0.04 * scale)
170
  pp, pn, pl = _sample_box_surface(wc, (wing_length, wing_width, wing_height), max(6, density // 3), 3)
171
- parts.append(pp)
172
- normals.append(pn)
173
- labels.append(pl)
 
174
 
175
  engine_radius = 0.14 * scale if spec.object_type != "fighter" else 0.1 * scale
176
  engine_length = 0.48 * scale
177
  engine_y_positions = np.linspace(-hull_w * 0.32, hull_w * 0.32, spec.engine_count)
 
 
 
178
  for ypos in engine_y_positions:
179
  ec = (-hull_len / 2 + engine_length * 0.3, ypos, 0.0)
180
  pp, pn, pl = _sample_cylinder_surface(ec, engine_radius, engine_length, "x", max(6, density // 3), 4)
181
- parts.append(pp)
182
- normals.append(pn)
183
- labels.append(pl)
 
184
 
185
  if spec.fin_height > 0:
186
  fin_center = (-hull_len * 0.25, 0.0, hull_h * 0.42)
187
  fin_size = (hull_len * 0.18, hull_w * 0.1, hull_h * max(spec.fin_height, 0.12))
188
- pp, pn, pl = _sample_box_surface(fin_center, fin_size, max(6, density // 3), 5)
189
- parts.append(pp)
190
- normals.append(pn)
191
- labels.append(pl)
192
 
193
  if spec.landing_gear:
194
  gear_x = np.array([-hull_len * 0.18, hull_len * 0.12])
195
  gear_y = np.array([-hull_w * 0.28, hull_w * 0.28])
 
 
 
196
  for gx in gear_x:
197
  for gy in gear_y:
198
  gc = (gx, gy, -hull_h * 0.45)
199
  pp, pn, pl = _sample_cylinder_surface(gc, 0.04 * scale, 0.22 * scale, "z", max(5, density // 5), 6)
200
- parts.append(pp)
201
- normals.append(pn)
202
- labels.append(pl)
203
-
204
- points = np.vstack(parts)
205
- point_normals = np.vstack(normals)
206
- point_labels = np.concatenate(labels)
207
 
208
- if spec.asymmetry > 0:
209
- mask = points[:, 1] > 0
210
- points[mask, 2] += spec.asymmetry * np.sin(points[mask, 0] * 2.0)
211
 
212
- return points.astype(np.float32), point_normals.astype(np.float32), point_labels.astype(np.int32), spec, parser_backend
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
 
215
  def points_to_mesh(points: np.ndarray, pitch: float = 0.08, padding: int = 5, sigma: float = 1.2, level: float = 0.11) -> trimesh.Trimesh:
@@ -242,58 +335,75 @@ def points_to_mesh(points: np.ndarray, pitch: float = 0.08, padding: int = 5, si
242
  return mesh
243
 
244
 
245
- def export_point_cloud_as_ply(points: np.ndarray, labels: np.ndarray, path: str) -> str:
246
- colors = np.array([
247
- [170, 170, 180],
248
- [120, 180, 255],
249
- [255, 190, 120],
250
- [180, 180, 255],
251
- [255, 120, 120],
252
- [200, 255, 180],
253
- [255, 255, 180],
254
- ], dtype=np.uint8)
255
- c = colors[labels % len(colors)]
256
- pc = trimesh.points.PointCloud(vertices=points, colors=c)
257
- pc.export(path)
258
- return path
259
-
260
-
261
- def export_mesh_as_glb(mesh: trimesh.Trimesh, path: str) -> str:
262
- mesh.visual.vertex_colors = np.tile(np.array([[185, 190, 200, 255]], dtype=np.uint8), (len(mesh.vertices), 1))
263
- mesh.export(path)
264
- return path
265
-
266
-
267
- def run_pipeline(
268
- prompt: str,
269
- detail: int = 24,
270
  voxel_pitch: float = 0.08,
271
- parser_mode: str = "heuristic",
272
- model_id: str | None = None,
273
- ) -> BuildArtifacts:
274
- points, normals, labels, spec, parser_backend = build_particle_blueprint(
275
- prompt,
276
- detail=detail,
277
- parser_mode=parser_mode,
278
- model_id=model_id,
279
- )
 
 
 
 
 
 
 
 
 
280
  mesh = points_to_mesh(points, pitch=voxel_pitch)
281
 
282
- out_dir = Path(tempfile.mkdtemp(prefix="particle_blueprint_"))
283
- ply_path = str(out_dir / "blueprint.ply")
284
- glb_path = str(out_dir / "mesh.glb")
285
- export_point_cloud_as_ply(points, labels, ply_path)
286
  export_mesh_as_glb(mesh, glb_path)
287
 
288
  summary = {
289
- "prompt": prompt,
290
- "parser_backend": parser_backend,
291
- "spec": spec.to_dict(),
292
- "point_count": int(len(points)),
 
293
  "vertex_count": int(len(mesh.vertices)),
294
  "face_count": int(len(mesh.faces)),
295
  "bounds": mesh.bounds.round(3).tolist(),
296
  "voxel_pitch": voxel_pitch,
 
 
 
 
 
 
 
297
  }
 
 
298
 
299
- return BuildArtifacts(ply_path=ply_path, glb_path=glb_path, summary=summary)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import tempfile
5
  from dataclasses import dataclass
6
  from pathlib import Path
7
+ from typing import Generator
8
 
9
  import numpy as np
10
  import trimesh
 
12
  from skimage import measure
13
 
14
  from llm_parser import DEFAULT_LOCAL_MODEL, parse_prompt_with_local_llm
15
+ from model_runtime import TARGET_OMNI_MODEL, ensure_target_model_cached
16
  from parser import PromptSpec, parse_prompt
17
 
18
 
 
109
  return pts, normals, labels
110
 
111
 
112
+ def export_point_cloud_as_ply(points: np.ndarray, labels: np.ndarray, path: str) -> str:
113
+ colors = np.array([
114
+ [170, 170, 180],
115
+ [120, 180, 255],
116
+ [255, 190, 120],
117
+ [180, 180, 255],
118
+ [255, 120, 120],
119
+ [200, 255, 180],
120
+ [255, 255, 180],
121
+ ], dtype=np.uint8)
122
+ c = colors[labels % len(colors)]
123
+ pc = trimesh.points.PointCloud(vertices=points, colors=c)
124
+ pc.export(path)
125
+ return path
126
+
127
+
128
+ def export_mesh_as_glb(mesh: trimesh.Trimesh, path: str) -> str:
129
+ mesh.visual.vertex_colors = np.tile(np.array([[185, 190, 200, 255]], dtype=np.uint8), (len(mesh.vertices), 1))
130
+ mesh.export(path)
131
+ return path
132
+
133
+
134
+ def _resolve_spec(prompt: str, parser_mode: str, model_id: str | None = None) -> tuple[PromptSpec, str]:
135
  parser_mode = (parser_mode or "heuristic").strip().lower()
 
136
  if parser_mode.startswith("local"):
137
  spec = parse_prompt_with_local_llm(prompt, model_id=model_id or DEFAULT_LOCAL_MODEL)
138
+ return spec, f"local_llm:{model_id or DEFAULT_LOCAL_MODEL}"
139
+ return parse_prompt(prompt), "heuristic"
140
+
141
+
142
+ def _iter_part_specs(spec: PromptSpec, detail: int):
143
  scale = SCALE_FACTORS[spec.scale]
144
  density = max(6, detail)
145
 
 
 
 
 
146
  hull_len = 2.8 * scale
147
  hull_w = 1.2 * scale
148
  hull_h = 0.8 * scale
149
 
150
  if spec.hull_style == "rounded":
151
+ yield "Hull", *_sample_ellipsoid_surface((0.0, 0.0, 0.0), (hull_len / 2, hull_w / 2, hull_h / 2), density, 0)
152
  elif spec.hull_style == "sleek":
153
  p1, n1, l1 = _sample_ellipsoid_surface((0.12 * scale, 0.0, 0.0), (hull_len / 2.3, hull_w / 2.8, hull_h / 2.6), density, 0)
154
+ p2, n2, l2 = _sample_box_surface((-0.15 * scale, 0.0, -0.02 * scale), (hull_len * 0.52, hull_w * 0.5, hull_h * 0.55), max(4, density // 2), 0)
155
+ yield "Hull", np.vstack([p1, p2]), np.vstack([n1, n2]), np.concatenate([l1, l2])
 
 
156
  else:
157
+ yield "Hull", *_sample_box_surface((0.0, 0.0, 0.0), (hull_len, hull_w, hull_h), density, 0)
 
 
 
158
 
159
  cockpit_center = (hull_len / 2 - hull_len * spec.cockpit_ratio * 0.8, 0.0, hull_h * 0.14)
160
+ yield "Cockpit", *_sample_ellipsoid_surface(cockpit_center, (hull_len * spec.cockpit_ratio, hull_w * 0.22, hull_h * 0.24), max(4, density // 2), 1)
 
 
 
161
 
162
  if spec.cargo_ratio > 0.16:
163
  cargo_center = (-hull_len * 0.18, 0.0, -hull_h * 0.06)
164
  cargo_size = (hull_len * spec.cargo_ratio, hull_w * 0.76, hull_h * 0.6)
165
+ yield "Cargo bay", *_sample_box_surface(cargo_center, cargo_size, max(4, density // 2), 2)
 
 
 
166
 
167
  if spec.wing_span > 0:
168
  wing_length = hull_len * 0.34
169
  wing_width = hull_w * 0.18
170
  wing_height = hull_h * 0.08
171
  yoff = hull_w * 0.45 + wing_width * 0.6
172
+ wing_parts = []
173
+ wing_normals = []
174
+ wing_labels = []
175
  for side in (-1, 1):
176
  wc = (-0.1 * scale, side * yoff, -0.04 * scale)
177
  pp, pn, pl = _sample_box_surface(wc, (wing_length, wing_width, wing_height), max(6, density // 3), 3)
178
+ wing_parts.append(pp)
179
+ wing_normals.append(pn)
180
+ wing_labels.append(pl)
181
+ yield "Wings", np.vstack(wing_parts), np.vstack(wing_normals), np.concatenate(wing_labels)
182
 
183
  engine_radius = 0.14 * scale if spec.object_type != "fighter" else 0.1 * scale
184
  engine_length = 0.48 * scale
185
  engine_y_positions = np.linspace(-hull_w * 0.32, hull_w * 0.32, spec.engine_count)
186
+ engine_parts = []
187
+ engine_normals = []
188
+ engine_labels = []
189
  for ypos in engine_y_positions:
190
  ec = (-hull_len / 2 + engine_length * 0.3, ypos, 0.0)
191
  pp, pn, pl = _sample_cylinder_surface(ec, engine_radius, engine_length, "x", max(6, density // 3), 4)
192
+ engine_parts.append(pp)
193
+ engine_normals.append(pn)
194
+ engine_labels.append(pl)
195
+ yield "Engines", np.vstack(engine_parts), np.vstack(engine_normals), np.concatenate(engine_labels)
196
 
197
  if spec.fin_height > 0:
198
  fin_center = (-hull_len * 0.25, 0.0, hull_h * 0.42)
199
  fin_size = (hull_len * 0.18, hull_w * 0.1, hull_h * max(spec.fin_height, 0.12))
200
+ yield "Fin", *_sample_box_surface(fin_center, fin_size, max(6, density // 3), 5)
 
 
 
201
 
202
  if spec.landing_gear:
203
  gear_x = np.array([-hull_len * 0.18, hull_len * 0.12])
204
  gear_y = np.array([-hull_w * 0.28, hull_w * 0.28])
205
+ gear_parts = []
206
+ gear_normals = []
207
+ gear_labels = []
208
  for gx in gear_x:
209
  for gy in gear_y:
210
  gc = (gx, gy, -hull_h * 0.45)
211
  pp, pn, pl = _sample_cylinder_surface(gc, 0.04 * scale, 0.22 * scale, "z", max(5, density // 5), 6)
212
+ gear_parts.append(pp)
213
+ gear_normals.append(pn)
214
+ gear_labels.append(pl)
215
+ yield "Landing gear", np.vstack(gear_parts), np.vstack(gear_normals), np.concatenate(gear_labels)
 
 
 
216
 
 
 
 
217
 
218
+ def iter_blueprint_session(
219
+ prompt: str,
220
+ detail: int = 24,
221
+ parser_mode: str = "heuristic",
222
+ model_id: str | None = None,
223
+ ) -> Generator[dict, None, dict]:
224
+ prompt = (prompt or "").strip()
225
+ if not prompt:
226
+ raise ValueError("Enter a prompt first.")
227
+
228
+ out_dir = Path(tempfile.mkdtemp(prefix="particle_blueprint_session_"))
229
+ yield {"status": "Parsing prompt and planning shape…", "stage_index": 0, "stage_count": 1, "session_dir": str(out_dir)}
230
+
231
+ spec, parser_backend = _resolve_spec(prompt, parser_mode=parser_mode, model_id=model_id)
232
+ stages = list(_iter_part_specs(spec, detail=detail))
233
+
234
+ all_points = []
235
+ all_normals = []
236
+ all_labels = []
237
+
238
+ for idx, (stage_name, points, normals, labels) in enumerate(stages, start=1):
239
+ if spec.asymmetry > 0 and stage_name in {"Hull", "Cockpit", "Cargo bay"}:
240
+ mask = points[:, 1] > 0
241
+ points = points.copy()
242
+ points[mask, 2] += spec.asymmetry * np.sin(points[mask, 0] * 2.0)
243
+
244
+ all_points.append(points)
245
+ all_normals.append(normals)
246
+ all_labels.append(labels)
247
+
248
+ merged_points = np.vstack(all_points).astype(np.float32)
249
+ merged_normals = np.vstack(all_normals).astype(np.float32)
250
+ merged_labels = np.concatenate(all_labels).astype(np.int32)
251
+
252
+ preview_path = str(out_dir / f"blueprint_stage_{idx:02d}.ply")
253
+ export_point_cloud_as_ply(merged_points, merged_labels, preview_path)
254
+
255
+ summary = {
256
+ "prompt": prompt,
257
+ "parser_backend": parser_backend,
258
+ "spec": spec.to_dict(),
259
+ "stage": stage_name,
260
+ "stage_index": idx,
261
+ "stage_count": len(stages),
262
+ "point_count": int(len(merged_points)),
263
+ }
264
+ yield {
265
+ "status": f"{stage_name} added ({idx}/{len(stages)})",
266
+ "blueprint_path": preview_path,
267
+ "summary": summary,
268
+ "stage_index": idx,
269
+ "stage_count": len(stages),
270
+ "session_dir": str(out_dir),
271
+ }
272
+
273
+ final_points = np.vstack(all_points).astype(np.float32)
274
+ final_normals = np.vstack(all_normals).astype(np.float32)
275
+ final_labels = np.concatenate(all_labels).astype(np.int32)
276
+
277
+ npz_path = str(out_dir / "blueprint_data.npz")
278
+ np.savez_compressed(npz_path, points=final_points, normals=final_normals, labels=final_labels)
279
+
280
+ final_ply = str(out_dir / "blueprint_final.ply")
281
+ export_point_cloud_as_ply(final_points, final_labels, final_ply)
282
+
283
+ state = {
284
+ "prompt": prompt,
285
+ "parser_backend": parser_backend,
286
+ "spec": spec.to_dict(),
287
+ "point_count": int(len(final_points)),
288
+ "session_dir": str(out_dir),
289
+ "npz_path": npz_path,
290
+ "blueprint_path": final_ply,
291
+ "target_model": TARGET_OMNI_MODEL,
292
+ }
293
+ yield {
294
+ "status": "Blueprint ready. Inspect it, then run mesh generation when happy.",
295
+ "blueprint_path": final_ply,
296
+ "summary": {
297
+ **state,
298
+ "stage": "complete",
299
+ },
300
+ "stage_index": len(stages),
301
+ "stage_count": len(stages),
302
+ "state": state,
303
+ "session_dir": str(out_dir),
304
+ }
305
+ return state
306
 
307
 
308
  def points_to_mesh(points: np.ndarray, pitch: float = 0.08, padding: int = 5, sigma: float = 1.2, level: float = 0.11) -> trimesh.Trimesh:
 
335
  return mesh
336
 
337
 
338
+ def iter_meshify_session(
339
+ state: dict,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  voxel_pitch: float = 0.08,
341
+ use_target_model_cache: bool = True,
342
+ ) -> Generator[dict, None, dict]:
343
+ if not state or not state.get("npz_path"):
344
+ raise ValueError("Generate a blueprint first.")
345
+
346
+ data = np.load(state["npz_path"])
347
+ points = data["points"].astype(np.float32)
348
+ labels = data["labels"].astype(np.int32)
349
+ session_dir = Path(state["session_dir"])
350
+
351
+ model_note = None
352
+ if use_target_model_cache:
353
+ yield {"status": f"Preparing target model cache for {TARGET_OMNI_MODEL}…"}
354
+ model_cache = ensure_target_model_cached(TARGET_OMNI_MODEL)
355
+ model_note = model_cache["message"]
356
+ yield {"status": model_note}
357
+
358
+ yield {"status": "Converting blueprint into a watertight mesh…"}
359
  mesh = points_to_mesh(points, pitch=voxel_pitch)
360
 
361
+ yield {"status": "Exporting GLB…"}
362
+ glb_path = str(session_dir / "mesh_final.glb")
 
 
363
  export_mesh_as_glb(mesh, glb_path)
364
 
365
  summary = {
366
+ **state,
367
+ "mesh_backend": "local_voxel_mesher",
368
+ "target_model_cached": bool(use_target_model_cache),
369
+ "target_model": TARGET_OMNI_MODEL,
370
+ "target_model_note": model_note,
371
  "vertex_count": int(len(mesh.vertices)),
372
  "face_count": int(len(mesh.faces)),
373
  "bounds": mesh.bounds.round(3).tolist(),
374
  "voxel_pitch": voxel_pitch,
375
+ "mesh_path": glb_path,
376
+ }
377
+ yield {
378
+ "status": "Mesh ready.",
379
+ "mesh_path": glb_path,
380
+ "summary": summary,
381
+ "mesh_file": glb_path,
382
  }
383
+ return summary
384
+
385
 
386
+ # Backward-compatible helper for older single-click flow.
387
+ def run_pipeline(
388
+ prompt: str,
389
+ detail: int = 24,
390
+ voxel_pitch: float = 0.08,
391
+ parser_mode: str = "heuristic",
392
+ model_id: str | None = None,
393
+ ) -> BuildArtifacts:
394
+ final_state = None
395
+ final_summary = None
396
+ blueprint_path = None
397
+ for update in iter_blueprint_session(prompt, detail=detail, parser_mode=parser_mode, model_id=model_id):
398
+ blueprint_path = update.get("blueprint_path", blueprint_path)
399
+ final_state = update.get("state", final_state)
400
+ final_summary = update.get("summary", final_summary)
401
+ mesh_summary = None
402
+ mesh_path = None
403
+ if final_state is None:
404
+ raise RuntimeError("Blueprint generation failed.")
405
+ for update in iter_meshify_session(final_state, voxel_pitch=voxel_pitch, use_target_model_cache=False):
406
+ mesh_path = update.get("mesh_path", mesh_path)
407
+ mesh_summary = update.get("summary", mesh_summary)
408
+ summary = mesh_summary or final_summary or {}
409
+ return BuildArtifacts(ply_path=blueprint_path or "", glb_path=mesh_path or "", summary=summary)
model_runtime.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Callable
6
+
7
+ from huggingface_hub import snapshot_download
8
+
9
+ TARGET_OMNI_MODEL = os.getenv("PB3D_TARGET_MODEL", "tencent/Hunyuan3D-Omni")
10
+ DEFAULT_CACHE_ROOT = Path(os.getenv("PB3D_MODEL_CACHE", "./models"))
11
+
12
+
13
+ def get_target_model_dir(model_id: str = TARGET_OMNI_MODEL) -> Path:
14
+ safe = model_id.replace("/", "--")
15
+ return DEFAULT_CACHE_ROOT / safe
16
+
17
+
18
+ def ensure_target_model_cached(
19
+ model_id: str = TARGET_OMNI_MODEL,
20
+ progress: Callable[[str], None] | None = None,
21
+ ) -> dict:
22
+ """
23
+ Best-effort local cache of the upstream target model repo.
24
+
25
+ This fetches the model repo into the Space filesystem so later integration work
26
+ can call the upstream inference stack directly. It does not assume internal file
27
+ names beyond the official repo id.
28
+ """
29
+ target_dir = get_target_model_dir(model_id)
30
+ target_dir.mkdir(parents=True, exist_ok=True)
31
+
32
+ if progress:
33
+ progress(f"Checking local cache for {model_id}…")
34
+
35
+ try:
36
+ local_path = snapshot_download(
37
+ repo_id=model_id,
38
+ local_dir=str(target_dir),
39
+ local_dir_use_symlinks=False,
40
+ resume_download=True,
41
+ )
42
+ return {
43
+ "ok": True,
44
+ "model_id": model_id,
45
+ "local_path": local_path,
46
+ "message": f"Cached {model_id} in {local_path}",
47
+ }
48
+ except Exception as exc: # pragma: no cover
49
+ return {
50
+ "ok": False,
51
+ "model_id": model_id,
52
+ "local_path": str(target_dir),
53
+ "message": f"Could not cache {model_id}: {exc}",
54
+ }
requirements.txt CHANGED
@@ -8,3 +8,4 @@ transformers>=4.49.0
8
  accelerate>=1.4.0
9
  torch>=2.6.0
10
  safetensors>=0.5.3
 
 
8
  accelerate>=1.4.0
9
  torch>=2.6.0
10
  safetensors>=0.5.3
11
+ spaces>=0.35.0