tomiconic commited on
Commit
af54811
·
verified ·
1 Parent(s): 77e37fc

Upload 9 files

Browse files
Files changed (5) hide show
  1. app.py +2 -2
  2. fallback_generator.py +82 -370
  3. generator.py +299 -0
  4. requirements.txt +2 -0
  5. viewer.py +6 -2
app.py CHANGED
@@ -155,7 +155,7 @@ def stream_mesh(state: dict, prepare_omni: bool, voxel_pitch: float):
155
  )
156
 
157
 
158
- with gr.Blocks(theme=gr.themes.Soft(), css=CSS, title=TITLE, fill_width=True) as demo:
159
  session_state = gr.State(value=None)
160
 
161
  gr.HTML(
@@ -267,4 +267,4 @@ with gr.Blocks(theme=gr.themes.Soft(), css=CSS, title=TITLE, fill_width=True) as
267
 
268
 
269
  if __name__ == "__main__":
270
- demo.queue(default_concurrency_limit=1).launch()
 
155
  )
156
 
157
 
158
+ with gr.Blocks(title=TITLE, fill_width=True) as demo:
159
  session_state = gr.State(value=None)
160
 
161
  gr.HTML(
 
267
 
268
 
269
  if __name__ == "__main__":
270
+ demo.queue(default_concurrency_limit=1).launch(theme=gr.themes.Soft(), css=CSS)
fallback_generator.py CHANGED
@@ -1,409 +1,121 @@
1
  from __future__ import annotations
2
 
3
- import math
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
11
- 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 model_runtime import TARGET_OMNI_MODEL, ensure_target_model_cached
16
- from parser import PromptSpec, parse_prompt
17
 
18
 
19
- @dataclass
20
- class BuildArtifacts:
21
- ply_path: str
22
- glb_path: str
23
- summary: dict
24
-
25
-
26
- SCALE_FACTORS = {
27
- "small": 1.0,
28
- "medium": 1.35,
29
- "large": 1.85,
30
- }
31
-
32
-
33
- def _sample_box_surface(center, size, density: int, label: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
34
- cx, cy, cz = center
35
- sx, sy, sz = size
36
- n = max(4, density)
37
- u = np.linspace(-0.5, 0.5, n)
38
- vv = np.linspace(-0.5, 0.5, n)
39
- pts = []
40
- normals = []
41
- labels = []
42
- for ax in (-1, 1):
43
- x = np.full((n, n), cx + ax * sx / 2)
44
- y, z = np.meshgrid(u * sy + cy, vv * sz + cz)
45
- pts.append(np.column_stack([x.ravel(), y.ravel(), z.ravel()]))
46
- normals.append(np.tile([ax, 0, 0], (n * n, 1)))
47
- labels.append(np.full(n * n, label))
48
- for ay in (-1, 1):
49
- y = np.full((n, n), cy + ay * sy / 2)
50
- x, z = np.meshgrid(u * sx + cx, vv * sz + cz)
51
- pts.append(np.column_stack([x.ravel(), y.ravel(), z.ravel()]))
52
- normals.append(np.tile([0, ay, 0], (n * n, 1)))
53
- labels.append(np.full(n * n, label))
54
- for az in (-1, 1):
55
- z = np.full((n, n), cz + az * sz / 2)
56
- x, y = np.meshgrid(u * sx + cx, vv * sy + cy)
57
- pts.append(np.column_stack([x.ravel(), y.ravel(), z.ravel()]))
58
- normals.append(np.tile([0, 0, az], (n * n, 1)))
59
- labels.append(np.full(n * n, label))
60
- return np.vstack(pts), np.vstack(normals), np.concatenate(labels)
61
-
62
-
63
- def _sample_ellipsoid_surface(center, radii, density: int, label: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
64
- cx, cy, cz = center
65
- rx, ry, rz = radii
66
- nu = max(16, density * 3)
67
- nv = max(10, density * 2)
68
- u = np.linspace(0, 2 * math.pi, nu, endpoint=False)
69
- v = np.linspace(-math.pi / 2, math.pi / 2, nv)
70
- uu, vv = np.meshgrid(u, v)
71
- x = cx + rx * np.cos(vv) * np.cos(uu)
72
- y = cy + ry * np.cos(vv) * np.sin(uu)
73
- z = cz + rz * np.sin(vv)
74
- pts = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
75
- normals = np.column_stack([
76
- (x - cx).ravel() / max(rx, 1e-6),
77
- (y - cy).ravel() / max(ry, 1e-6),
78
- (z - cz).ravel() / max(rz, 1e-6),
79
- ])
80
- normals /= np.linalg.norm(normals, axis=1, keepdims=True) + 1e-8
81
- labels = np.full(len(pts), label)
82
- return pts, normals, labels
83
-
84
-
85
- def _sample_cylinder_surface(center, radius, length, axis: str, density: int, label: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
86
- cx, cy, cz = center
87
- nt = max(18, density * 4)
88
- nl = max(6, density)
89
- theta = np.linspace(0, 2 * math.pi, nt, endpoint=False)
90
- line = np.linspace(-length / 2, length / 2, nl)
91
- tt, ll = np.meshgrid(theta, line)
92
- if axis == "x":
93
- x = cx + ll
94
- y = cy + radius * np.cos(tt)
95
- z = cz + radius * np.sin(tt)
96
- normals = np.column_stack([np.zeros(x.size), np.cos(tt).ravel(), np.sin(tt).ravel()])
97
- elif axis == "y":
98
- x = cx + radius * np.cos(tt)
99
- y = cy + ll
100
- z = cz + radius * np.sin(tt)
101
- normals = np.column_stack([np.cos(tt).ravel(), np.zeros(x.size), np.sin(tt).ravel()])
102
- else:
103
- x = cx + radius * np.cos(tt)
104
- y = cy + radius * np.sin(tt)
105
- z = cz + ll
106
- normals = np.column_stack([np.cos(tt).ravel(), np.sin(tt).ravel(), np.zeros(x.size)])
107
- pts = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
108
- labels = np.full(len(pts), label)
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:
309
- mins = points.min(axis=0) - padding * pitch
310
- maxs = points.max(axis=0) + padding * pitch
311
- dims = np.ceil((maxs - mins) / pitch).astype(int) + 1
312
- dims = np.clip(dims, 24, 192)
313
-
314
- grid = np.zeros(tuple(dims.tolist()), dtype=np.float32)
315
- coords = ((points - mins) / pitch).astype(int)
316
- coords = np.clip(coords, 0, dims - 1)
317
- np.add.at(grid, (coords[:, 0], coords[:, 1], coords[:, 2]), 1.0)
318
-
319
- grid = ndimage.gaussian_filter(grid, sigma=sigma)
320
- verts, faces, normals, _ = measure.marching_cubes(grid, level=level)
321
- verts = verts * pitch + mins
322
-
323
- mesh = trimesh.Trimesh(vertices=verts, faces=faces, vertex_normals=normals, process=True)
324
- mesh.update_faces(mesh.nondegenerate_faces())
325
- mesh.update_faces(mesh.unique_faces())
326
- mesh.remove_unreferenced_vertices()
327
- try:
328
- mesh.fill_holes()
329
- except Exception:
330
- pass
331
- try:
332
- trimesh.smoothing.filter_humphrey(mesh, iterations=2)
333
- except Exception:
334
- pass
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)
 
1
  from __future__ import annotations
2
 
 
3
  import tempfile
 
4
  from pathlib import Path
5
  from typing import Generator
6
 
7
  import numpy as np
8
  import trimesh
 
 
9
 
10
+ from generator import build_particle_blueprint, export_point_cloud_as_ply, points_to_mesh
11
+ from viewer import point_cloud_viewer_html
 
12
 
13
 
14
+ def _normalize_mesh_to_glb(mesh: trimesh.Trimesh, out_path: Path) -> str:
15
+ mesh = mesh.copy()
16
+ mesh.remove_unreferenced_vertices()
17
+ try:
18
+ mesh.remove_degenerate_faces()
19
+ except Exception:
20
+ pass
21
+ try:
22
+ mesh.remove_duplicate_faces()
23
+ except Exception:
24
+ pass
25
+ centroid = mesh.bounding_box.centroid
26
+ mesh.apply_translation(-centroid)
27
+ scale = float(max(mesh.extents)) if len(mesh.vertices) else 1.0
28
+ if scale <= 0:
29
+ scale = 1.0
30
+ mesh.apply_scale(1.0 / scale)
31
+ mesh.export(out_path)
32
+ return str(out_path)
33
+
34
+
35
+ def iter_blueprint_session(prompt: str, detail: int = 22, parser_mode: str = "heuristic") -> Generator[dict, None, dict]:
36
+ session_dir = Path(tempfile.mkdtemp(prefix="pb3d_fallback_"))
37
+ yield {"status": "Building scaffold plan…", "session_dir": str(session_dir)}
38
+
39
+ points, normals, labels, spec, parser_backend = build_particle_blueprint(
40
+ prompt=prompt,
41
+ detail=int(detail),
42
+ parser_mode=parser_mode,
43
+ )
44
+
45
+ blueprint_path = export_point_cloud_as_ply(points, labels, str(session_dir / "blueprint.ply"))
46
+
47
+ stages = [0.18, 0.42, 0.68, 1.0]
48
+ for i, frac in enumerate(stages, start=1):
49
+ count = max(180, int(len(points) * frac))
50
+ preview = points[:count]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  yield {
52
+ "status": f"Blueprint forming ({i}/{len(stages)})",
53
+ "summary": {
54
+ "prompt": prompt,
55
+ "parser_backend": parser_backend,
56
+ "spec": spec.to_dict() if hasattr(spec, "to_dict") else {},
57
+ "point_count": int(count),
58
+ "stage": i,
59
+ "stage_count": len(stages),
60
+ "mode": "fallback_scaffold",
61
+ },
62
+ "blueprint_path": blueprint_path,
63
+ "state": {
64
+ "session_dir": str(session_dir),
65
+ "blueprint_path": blueprint_path,
66
+ "points_path": str(session_dir / "points.npy"),
67
+ "labels_path": str(session_dir / "labels.npy"),
68
+ "prompt": prompt,
69
+ "parser_backend": parser_backend,
70
+ "spec": spec.to_dict() if hasattr(spec, "to_dict") else {},
71
+ },
72
  }
73
 
74
+ np.save(session_dir / "points.npy", points)
75
+ np.save(session_dir / "labels.npy", labels)
 
 
 
 
 
 
 
76
 
77
  state = {
78
+ "session_dir": str(session_dir),
79
+ "blueprint_path": blueprint_path,
80
+ "points_path": str(session_dir / "points.npy"),
81
+ "labels_path": str(session_dir / "labels.npy"),
82
  "prompt": prompt,
83
  "parser_backend": parser_backend,
84
+ "spec": spec.to_dict() if hasattr(spec, "to_dict") else {},
85
+ "point_count": int(len(points)),
 
 
 
 
86
  }
87
  yield {
88
+ "status": "Blueprint ready. Inspect it, then make the mesh when happy.",
89
+ "viewer_html": point_cloud_viewer_html(points, status=f"Blueprint • {len(points)} points"),
90
+ "summary": {**state, "mode": "fallback_scaffold"},
91
+ "blueprint_path": blueprint_path,
 
 
 
 
92
  "state": state,
 
93
  }
94
  return state
95
 
96
 
97
+ def iter_meshify_session(state: dict, voxel_pitch: float = 0.085, use_target_model_cache: bool = True) -> Generator[dict, None, dict]:
98
+ points_path = state.get("points_path")
99
+ if not points_path or not Path(points_path).exists():
100
+ raise RuntimeError("Blueprint points were not found. Generate the blueprint again.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
+ yield {"status": "Converting blueprint into a mesh…"}
103
+ points = np.load(points_path)
104
+ mesh = points_to_mesh(points, pitch=float(voxel_pitch))
105
  session_dir = Path(state["session_dir"])
106
+ glb_path = _normalize_mesh_to_glb(mesh, session_dir / "fallback_mesh.glb")
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  summary = {
109
  **state,
110
+ "mesh_path": glb_path,
 
 
 
111
  "vertex_count": int(len(mesh.vertices)),
112
  "face_count": int(len(mesh.faces)),
113
+ "mesh_source": "fallback_voxel_mesher",
 
 
114
  }
115
  yield {
116
  "status": "Mesh ready.",
117
  "mesh_path": glb_path,
 
118
  "mesh_file": glb_path,
119
+ "summary": summary,
120
  }
121
  return summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
generator.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ 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
11
+ 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
+
18
+ @dataclass
19
+ class BuildArtifacts:
20
+ ply_path: str
21
+ glb_path: str
22
+ summary: dict
23
+
24
+
25
+ SCALE_FACTORS = {
26
+ "small": 1.0,
27
+ "medium": 1.35,
28
+ "large": 1.85,
29
+ }
30
+
31
+
32
+ def _sample_box_surface(center, size, density: int, label: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
33
+ cx, cy, cz = center
34
+ sx, sy, sz = size
35
+ n = max(4, density)
36
+ u = np.linspace(-0.5, 0.5, n)
37
+ vv = np.linspace(-0.5, 0.5, n)
38
+ pts = []
39
+ normals = []
40
+ labels = []
41
+ for ax in (-1, 1):
42
+ x = np.full((n, n), cx + ax * sx / 2)
43
+ y, z = np.meshgrid(u * sy + cy, vv * sz + cz)
44
+ pts.append(np.column_stack([x.ravel(), y.ravel(), z.ravel()]))
45
+ normals.append(np.tile([ax, 0, 0], (n * n, 1)))
46
+ labels.append(np.full(n * n, label))
47
+ for ay in (-1, 1):
48
+ y = np.full((n, n), cy + ay * sy / 2)
49
+ x, z = np.meshgrid(u * sx + cx, vv * sz + cz)
50
+ pts.append(np.column_stack([x.ravel(), y.ravel(), z.ravel()]))
51
+ normals.append(np.tile([0, ay, 0], (n * n, 1)))
52
+ labels.append(np.full(n * n, label))
53
+ for az in (-1, 1):
54
+ z = np.full((n, n), cz + az * sz / 2)
55
+ x, y = np.meshgrid(u * sx + cx, vv * sy + cy)
56
+ pts.append(np.column_stack([x.ravel(), y.ravel(), z.ravel()]))
57
+ normals.append(np.tile([0, 0, az], (n * n, 1)))
58
+ labels.append(np.full(n * n, label))
59
+ return np.vstack(pts), np.vstack(normals), np.concatenate(labels)
60
+
61
+
62
+ def _sample_ellipsoid_surface(center, radii, density: int, label: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
63
+ cx, cy, cz = center
64
+ rx, ry, rz = radii
65
+ nu = max(16, density * 3)
66
+ nv = max(10, density * 2)
67
+ u = np.linspace(0, 2 * math.pi, nu, endpoint=False)
68
+ v = np.linspace(-math.pi / 2, math.pi / 2, nv)
69
+ uu, vv = np.meshgrid(u, v)
70
+ x = cx + rx * np.cos(vv) * np.cos(uu)
71
+ y = cy + ry * np.cos(vv) * np.sin(uu)
72
+ z = cz + rz * np.sin(vv)
73
+ pts = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
74
+ normals = np.column_stack([
75
+ (x - cx).ravel() / max(rx, 1e-6),
76
+ (y - cy).ravel() / max(ry, 1e-6),
77
+ (z - cz).ravel() / max(rz, 1e-6),
78
+ ])
79
+ normals /= np.linalg.norm(normals, axis=1, keepdims=True) + 1e-8
80
+ labels = np.full(len(pts), label)
81
+ return pts, normals, labels
82
+
83
+
84
+ def _sample_cylinder_surface(center, radius, length, axis: str, density: int, label: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
85
+ cx, cy, cz = center
86
+ nt = max(18, density * 4)
87
+ nl = max(6, density)
88
+ theta = np.linspace(0, 2 * math.pi, nt, endpoint=False)
89
+ line = np.linspace(-length / 2, length / 2, nl)
90
+ tt, ll = np.meshgrid(theta, line)
91
+ if axis == "x":
92
+ x = cx + ll
93
+ y = cy + radius * np.cos(tt)
94
+ z = cz + radius * np.sin(tt)
95
+ normals = np.column_stack([np.zeros(x.size), np.cos(tt).ravel(), np.sin(tt).ravel()])
96
+ elif axis == "y":
97
+ x = cx + radius * np.cos(tt)
98
+ y = cy + ll
99
+ z = cz + radius * np.sin(tt)
100
+ normals = np.column_stack([np.cos(tt).ravel(), np.zeros(x.size), np.sin(tt).ravel()])
101
+ else:
102
+ x = cx + radius * np.cos(tt)
103
+ y = cy + radius * np.sin(tt)
104
+ z = cz + ll
105
+ normals = np.column_stack([np.cos(tt).ravel(), np.sin(tt).ravel(), np.zeros(x.size)])
106
+ pts = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
107
+ labels = np.full(len(pts), label)
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:
216
+ mins = points.min(axis=0) - padding * pitch
217
+ maxs = points.max(axis=0) + padding * pitch
218
+ dims = np.ceil((maxs - mins) / pitch).astype(int) + 1
219
+ dims = np.clip(dims, 24, 192)
220
+
221
+ grid = np.zeros(tuple(dims.tolist()), dtype=np.float32)
222
+ coords = ((points - mins) / pitch).astype(int)
223
+ coords = np.clip(coords, 0, dims - 1)
224
+ np.add.at(grid, (coords[:, 0], coords[:, 1], coords[:, 2]), 1.0)
225
+
226
+ grid = ndimage.gaussian_filter(grid, sigma=sigma)
227
+ verts, faces, normals, _ = measure.marching_cubes(grid, level=level)
228
+ verts = verts * pitch + mins
229
+
230
+ mesh = trimesh.Trimesh(vertices=verts, faces=faces, vertex_normals=normals, process=True)
231
+ mesh.update_faces(mesh.nondegenerate_faces())
232
+ mesh.update_faces(mesh.unique_faces())
233
+ mesh.remove_unreferenced_vertices()
234
+ try:
235
+ mesh.fill_holes()
236
+ except Exception:
237
+ pass
238
+ try:
239
+ trimesh.smoothing.filter_humphrey(mesh, iterations=2)
240
+ except Exception:
241
+ pass
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)
requirements.txt CHANGED
@@ -10,3 +10,5 @@ pillow>=10.4.0
10
  torch>=2.5.0,<2.6.0
11
  torchvision>=0.20.0,<0.21.0
12
  torchaudio>=2.5.0,<2.6.0
 
 
 
10
  torch>=2.5.0,<2.6.0
11
  torchvision>=0.20.0,<0.21.0
12
  torchaudio>=2.5.0,<2.6.0
13
+
14
+ spaces>=0.35.0
viewer.py CHANGED
@@ -84,8 +84,9 @@ def point_cloud_viewer_html(points: np.ndarray, status: str = "Blueprint") -> st
84
  <script src="https://unpkg.com/three@0.160.0/examples/js/controls/OrbitControls.js"></script>
85
  <script>
86
  (() => {{
87
- const canvas = document.currentScript.previousElementSibling;
88
- const holder = canvas.parentElement;
 
89
  if (!window.THREE || !window.THREE.OrbitControls) {{
90
  holder.innerHTML = `<div style='height:100%;display:flex;align-items:center;justify-content:center;color:#eef2ff;font-family:Inter,system-ui,sans-serif;'>Viewer failed to load.</div>`;
91
  return;
@@ -130,6 +131,9 @@ def point_cloud_viewer_html(points: np.ndarray, status: str = "Blueprint") -> st
130
  grid.position.y = -0.72;
131
  scene.add(grid);
132
 
 
 
 
133
  const lightA = new THREE.DirectionalLight(0xffffff, 1.8);
134
  lightA.position.set(2, 3, 2);
135
  scene.add(lightA);
 
84
  <script src="https://unpkg.com/three@0.160.0/examples/js/controls/OrbitControls.js"></script>
85
  <script>
86
  (() => {{
87
+ const root = document.currentScript.previousElementSibling.previousElementSibling.previousElementSibling;
88
+ const canvas = root.querySelector('canvas');
89
+ const holder = root;
90
  if (!window.THREE || !window.THREE.OrbitControls) {{
91
  holder.innerHTML = `<div style='height:100%;display:flex;align-items:center;justify-content:center;color:#eef2ff;font-family:Inter,system-ui,sans-serif;'>Viewer failed to load.</div>`;
92
  return;
 
131
  grid.position.y = -0.72;
132
  scene.add(grid);
133
 
134
+ const axes = new THREE.AxesHelper(0.7);
135
+ scene.add(axes);
136
+
137
  const lightA = new THREE.DirectionalLight(0xffffff, 1.8);
138
  lightA.position.set(2, 3, 2);
139
  scene.add(lightA);