tomiconic commited on
Commit
7dfd6a8
·
verified ·
1 Parent(s): 0256d03

Upload 8 files

Browse files
Files changed (5) hide show
  1. README.md +18 -0
  2. app.py +2 -2
  3. generator.py +299 -0
  4. llm_parser.py +167 -0
  5. parser.py +148 -0
README.md CHANGED
@@ -102,3 +102,21 @@ This is still the right honest framing:
102
  - the scaffold becomes the mesh
103
 
104
  That middle layer is the product.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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`
118
+ - `packages.txt`
119
+ - `README.md`
120
+ - `LICENSE`
121
+
122
+ No `src/` folder is needed.
app.py CHANGED
@@ -2,8 +2,8 @@ from __future__ import annotations
2
 
3
  import gradio as gr
4
 
5
- from src.generator import run_pipeline
6
- from src.llm_parser import DEFAULT_LOCAL_MODEL, MODEL_PRESETS
7
 
8
 
9
  TITLE = "Particle Blueprint 3D"
 
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"
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)
llm_parser.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from functools import lru_cache
7
+ from typing import Any
8
+
9
+ from parser import PromptSpec, merge_prompt_specs, parse_prompt
10
+
11
+ try:
12
+ import spaces # type: ignore
13
+ except Exception: # pragma: no cover
14
+ class _SpacesShim:
15
+ @staticmethod
16
+ def GPU(*args, **kwargs):
17
+ def decorator(fn):
18
+ return fn
19
+ return decorator
20
+
21
+ spaces = _SpacesShim() # type: ignore
22
+
23
+
24
+ DEFAULT_LOCAL_MODEL = os.getenv("PB3D_LOCAL_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")
25
+ MODEL_PRESETS = {
26
+ "Qwen 2.5 1.5B": "Qwen/Qwen2.5-1.5B-Instruct",
27
+ "SmolLM2 1.7B": "HuggingFaceTB/SmolLM2-1.7B-Instruct",
28
+ }
29
+
30
+ JSON_SCHEMA_HINT = {
31
+ "object_type": ["cargo_hauler", "fighter", "shuttle", "freighter", "dropship", "drone"],
32
+ "scale": ["small", "medium", "large"],
33
+ "hull_style": ["boxy", "rounded", "sleek"],
34
+ "engine_count": "integer 1-6",
35
+ "wing_span": "float 0.0-0.6",
36
+ "cargo_ratio": "float 0.0-0.65",
37
+ "cockpit_ratio": "float 0.10-0.30",
38
+ "fin_height": "float 0.0-0.3",
39
+ "landing_gear": "boolean",
40
+ "asymmetry": "float 0.0-0.2",
41
+ "notes": "short string",
42
+ }
43
+
44
+
45
+ def _clamp(value: float, low: float, high: float) -> float:
46
+ return max(low, min(high, value))
47
+
48
+
49
+ @lru_cache(maxsize=2)
50
+ def _load_generation_components(model_id: str):
51
+ from transformers import AutoModelForCausalLM, AutoTokenizer
52
+ import torch
53
+
54
+ tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
55
+ if tokenizer.pad_token is None:
56
+ tokenizer.pad_token = tokenizer.eos_token
57
+
58
+ has_cuda = torch.cuda.is_available()
59
+ torch_dtype = torch.bfloat16 if has_cuda else torch.float32
60
+ model = AutoModelForCausalLM.from_pretrained(
61
+ model_id,
62
+ torch_dtype=torch_dtype,
63
+ device_map="auto",
64
+ low_cpu_mem_usage=True,
65
+ trust_remote_code=True,
66
+ )
67
+ return tokenizer, model
68
+
69
+
70
+ @spaces.GPU(duration=45)
71
+ def _generate_structured_json(prompt: str, model_id: str) -> dict[str, Any]:
72
+ import torch
73
+
74
+ tokenizer, model = _load_generation_components(model_id)
75
+
76
+ system = (
77
+ "You are a compact design parser for a procedural 3D generator. "
78
+ "Convert the user request into a single JSON object and output JSON only."
79
+ )
80
+ user = (
81
+ "Return a JSON object using this schema: "
82
+ f"{json.dumps(JSON_SCHEMA_HINT)}\n"
83
+ "Rules: choose the closest allowed enum values, stay conservative, infer hard-surface sci-fi vehicle structure, "
84
+ "never explain anything, never use markdown fences, and keep notes brief.\n"
85
+ f"Prompt: {prompt}"
86
+ )
87
+
88
+ messages = [
89
+ {"role": "system", "content": system},
90
+ {"role": "user", "content": user},
91
+ ]
92
+
93
+ if hasattr(tokenizer, "apply_chat_template"):
94
+ rendered = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
95
+ else:
96
+ rendered = f"System: {system}\nUser: {user}\nAssistant:"
97
+
98
+ inputs = tokenizer(rendered, return_tensors="pt")
99
+ model_device = getattr(model, "device", None)
100
+ if model_device is not None:
101
+ inputs = {k: v.to(model_device) for k, v in inputs.items()}
102
+
103
+ with torch.no_grad():
104
+ output = model.generate(
105
+ **inputs,
106
+ max_new_tokens=220,
107
+ do_sample=False,
108
+ temperature=None,
109
+ top_p=None,
110
+ repetition_penalty=1.02,
111
+ pad_token_id=tokenizer.pad_token_id,
112
+ eos_token_id=tokenizer.eos_token_id,
113
+ )
114
+
115
+ new_tokens = output[0][inputs["input_ids"].shape[1]:]
116
+ text = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
117
+
118
+ match = re.search(r"\{.*\}", text, flags=re.S)
119
+ if not match:
120
+ raise ValueError("Local model did not return JSON.")
121
+ return json.loads(match.group(0))
122
+
123
+
124
+ def _normalize_llm_payload(payload: dict[str, Any], original_prompt: str) -> PromptSpec:
125
+ def get_str(name: str, default: str) -> str:
126
+ value = str(payload.get(name, default)).strip().lower()
127
+ return value or default
128
+
129
+ def get_int(name: str, default: int, low: int, high: int) -> int:
130
+ try:
131
+ return int(_clamp(int(payload.get(name, default)), low, high))
132
+ except Exception:
133
+ return default
134
+
135
+ def get_float(name: str, default: float, low: float, high: float) -> float:
136
+ try:
137
+ return float(_clamp(float(payload.get(name, default)), low, high))
138
+ except Exception:
139
+ return default
140
+
141
+ landing_raw = payload.get("landing_gear", True)
142
+ if isinstance(landing_raw, bool):
143
+ landing_gear = landing_raw
144
+ else:
145
+ landing_gear = str(landing_raw).strip().lower() in {"1", "true", "yes", "y"}
146
+
147
+ return PromptSpec(
148
+ object_type=get_str("object_type", "cargo_hauler"),
149
+ scale=get_str("scale", "small"),
150
+ hull_style=get_str("hull_style", "boxy"),
151
+ engine_count=get_int("engine_count", 2, 1, 6),
152
+ wing_span=get_float("wing_span", 0.2, 0.0, 0.6),
153
+ cargo_ratio=get_float("cargo_ratio", 0.38, 0.0, 0.65),
154
+ cockpit_ratio=get_float("cockpit_ratio", 0.18, 0.10, 0.30),
155
+ fin_height=get_float("fin_height", 0.0, 0.0, 0.3),
156
+ landing_gear=landing_gear,
157
+ asymmetry=get_float("asymmetry", 0.0, 0.0, 0.2),
158
+ notes=str(payload.get("notes", original_prompt)).strip() or original_prompt,
159
+ )
160
+
161
+
162
+ def parse_prompt_with_local_llm(prompt: str, model_id: str | None = None) -> PromptSpec:
163
+ model_id = model_id or DEFAULT_LOCAL_MODEL
164
+ heuristic = parse_prompt(prompt)
165
+ payload = _generate_structured_json(prompt=prompt, model_id=model_id)
166
+ llm_spec = _normalize_llm_payload(payload, original_prompt=prompt)
167
+ return merge_prompt_specs(heuristic, llm_spec)
parser.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, asdict
5
+
6
+
7
+ @dataclass
8
+ class PromptSpec:
9
+ object_type: str = "cargo_hauler"
10
+ scale: str = "small"
11
+ hull_style: str = "boxy"
12
+ engine_count: int = 2
13
+ wing_span: float = 0.2
14
+ cargo_ratio: float = 0.38
15
+ cockpit_ratio: float = 0.18
16
+ fin_height: float = 0.0
17
+ landing_gear: bool = True
18
+ asymmetry: float = 0.0
19
+ notes: str = ""
20
+
21
+ def to_dict(self) -> dict:
22
+ return asdict(self)
23
+
24
+
25
+ TYPE_KEYWORDS = {
26
+ "fighter": "fighter",
27
+ "combat": "fighter",
28
+ "interceptor": "fighter",
29
+ "shuttle": "shuttle",
30
+ "freighter": "freighter",
31
+ "hauler": "cargo_hauler",
32
+ "cargo": "cargo_hauler",
33
+ "transport": "cargo_hauler",
34
+ "dropship": "dropship",
35
+ "drone": "drone",
36
+ }
37
+
38
+ STYLE_KEYWORDS = {
39
+ "boxy": "boxy",
40
+ "industrial": "boxy",
41
+ "hard-surface": "boxy",
42
+ "rounded": "rounded",
43
+ "sleek": "sleek",
44
+ "streamlined": "sleek",
45
+ "brutalist": "boxy",
46
+ }
47
+
48
+ SCALE_KEYWORDS = {
49
+ "tiny": "small",
50
+ "small": "small",
51
+ "compact": "small",
52
+ "medium": "medium",
53
+ "mid-size": "medium",
54
+ "large": "large",
55
+ "heavy": "large",
56
+ "huge": "large",
57
+ }
58
+
59
+ VALID_OBJECT_TYPES = {"cargo_hauler", "fighter", "shuttle", "freighter", "dropship", "drone"}
60
+ VALID_SCALES = {"small", "medium", "large"}
61
+ VALID_HULL_STYLES = {"boxy", "rounded", "sleek"}
62
+
63
+
64
+ def _clamp(value: float, low: float, high: float) -> float:
65
+ return max(low, min(high, value))
66
+
67
+
68
+ def merge_prompt_specs(primary: PromptSpec, secondary: PromptSpec) -> PromptSpec:
69
+ merged = PromptSpec(**primary.to_dict())
70
+
71
+ if secondary.object_type in VALID_OBJECT_TYPES:
72
+ merged.object_type = secondary.object_type
73
+ if secondary.scale in VALID_SCALES:
74
+ merged.scale = secondary.scale
75
+ if secondary.hull_style in VALID_HULL_STYLES:
76
+ merged.hull_style = secondary.hull_style
77
+
78
+ merged.engine_count = int(_clamp(secondary.engine_count, 1, 6))
79
+ merged.wing_span = float(_clamp(secondary.wing_span, 0.0, 0.6))
80
+ merged.cargo_ratio = float(_clamp(secondary.cargo_ratio, 0.0, 0.65))
81
+ merged.cockpit_ratio = float(_clamp(secondary.cockpit_ratio, 0.10, 0.30))
82
+ merged.fin_height = float(_clamp(secondary.fin_height, 0.0, 0.3))
83
+ merged.landing_gear = bool(secondary.landing_gear)
84
+ merged.asymmetry = float(_clamp(secondary.asymmetry, 0.0, 0.2))
85
+ merged.notes = secondary.notes or primary.notes
86
+
87
+ if merged.object_type in {"fighter", "drone"}:
88
+ merged.cargo_ratio = min(merged.cargo_ratio, 0.20)
89
+ if merged.hull_style == "boxy":
90
+ merged.hull_style = "sleek"
91
+
92
+ return merged
93
+
94
+
95
+ def parse_prompt(prompt: str) -> PromptSpec:
96
+ text = prompt.lower().strip()
97
+ spec = PromptSpec(notes=prompt.strip())
98
+
99
+ for key, value in TYPE_KEYWORDS.items():
100
+ if key in text:
101
+ spec.object_type = value
102
+ break
103
+
104
+ for key, value in STYLE_KEYWORDS.items():
105
+ if key in text:
106
+ spec.hull_style = value
107
+ break
108
+
109
+ for key, value in SCALE_KEYWORDS.items():
110
+ if key in text:
111
+ spec.scale = value
112
+ break
113
+
114
+ if any(word in text for word in ["wing", "wings"]):
115
+ spec.wing_span = 0.42 if spec.object_type == "fighter" else 0.28
116
+ if any(word in text for word in ["no wings", "wingless"]):
117
+ spec.wing_span = 0.0
118
+
119
+ if any(word in text for word in ["cargo bay", "cargo hold", "container", "freight"]):
120
+ spec.cargo_ratio = 0.48
121
+
122
+ if any(word in text for word in ["big cockpit", "large cockpit", "glass nose"]):
123
+ spec.cockpit_ratio = 0.24
124
+ if any(word in text for word in ["small cockpit", "tiny cockpit"]):
125
+ spec.cockpit_ratio = 0.13
126
+
127
+ if any(word in text for word in ["fin", "tail", "vertical stabilizer"]):
128
+ spec.fin_height = 0.18 if spec.object_type != "fighter" else 0.12
129
+
130
+ if any(word in text for word in ["hover", "hovercraft", "antigrav"]):
131
+ spec.landing_gear = False
132
+
133
+ if spec.object_type in {"fighter", "drone"}:
134
+ spec.engine_count = 1 if "single engine" in text else 2
135
+ spec.cargo_ratio = min(spec.cargo_ratio, 0.18)
136
+ spec.hull_style = "sleek"
137
+ elif spec.object_type in {"cargo_hauler", "freighter", "dropship"}:
138
+ spec.engine_count = 4 if any(x in text for x in ["4 engine", "four engine", "quad engine"]) else 2
139
+ spec.hull_style = "boxy" if spec.hull_style == "sleek" else spec.hull_style
140
+
141
+ numeric_engine = re.search(r"(\d+)\s*(?:engine|engines)", text)
142
+ if numeric_engine:
143
+ spec.engine_count = max(1, min(6, int(numeric_engine.group(1))))
144
+
145
+ if any(word in text for word in ["asymmetric", "uneven", "offset"]):
146
+ spec.asymmetry = 0.12
147
+
148
+ return spec