sdforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ // --- Standard Boolean Operations ---
2
+ // These operate on vec4(distance, material_id, 0, 0)
3
+
4
+ vec4 opU(vec4 a, vec4 b) {
5
+ return (a.x < b.x) ? a : b;
6
+ }
7
+
8
+ vec4 opI(vec4 a, vec4 b) {
9
+ return (a.x > b.x) ? a : b;
10
+ }
11
+
12
+ vec4 opS(vec4 a, vec4 b) {
13
+ return opI(a, vec4(-b.x, b.y, b.z, b.w));
14
+ }
15
+
16
+
17
+ // --- Smooth Boolean Operations ---
18
+
19
+ vec4 sUnion(vec4 a, vec4 b, float k )
20
+ {
21
+ float h = clamp( 0.5 + 0.5*(b.x-a.x)/k, 0.0, 1.0 );
22
+ float dist = mix( b.x, a.x, h ) - k*h*(1.0-h);
23
+ // Return the material of the object that is closer at the blend point
24
+ return (a.x < b.x) ? vec4(dist, a.y, a.z, a.w) : vec4(dist, b.y, b.z, b.w);
25
+ }
26
+
27
+ vec4 sIntersect(vec4 a, vec4 b, float k )
28
+ {
29
+ float h = clamp( 0.5 - 0.5*(b.x-a.x)/k, 0.0, 1.0 );
30
+ float dist = mix( b.x, a.x, h ) + k*h*(1.0-h);
31
+ return (a.x > b.x) ? vec4(dist, a.y, a.z, a.w) : vec4(dist, b.y, b.z, b.w);
32
+ }
33
+
34
+ vec4 sDifference(vec4 a, vec4 b, float k )
35
+ {
36
+ float h = clamp( 0.5 - 0.5*(b.x+a.x)/k, 0.0, 1.0 );
37
+ float dist = mix( b.x, -a.x, h ) + k*h*(1.0-h);
38
+ // Difference is tricky, it usually inherits the material of the first object
39
+ return vec4(dist, a.y, a.z, a.w);
40
+ }
@@ -0,0 +1,79 @@
1
+ // sdforge/glsl/sdf/primitives.glsl
2
+
3
+ // --- 3D Primitives ---
4
+
5
+ float sdSphere(in vec3 p, in float r) {
6
+ return length(p) - r;
7
+ }
8
+
9
+ float sdBox(in vec3 p, in vec3 b) {
10
+ vec3 q = abs(p) - b;
11
+ return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
12
+ }
13
+
14
+ float sdRoundedBox(in vec3 p, in vec3 b, in float r) {
15
+ vec3 q = abs(p) - b;
16
+ return length(max(q, 0.0)) - r;
17
+ }
18
+
19
+ float sdTorus(in vec3 p, in vec2 t) { // t.x=major radius, t.y=minor radius
20
+ vec2 q = vec2(length(p.xz) - t.x, p.y);
21
+ return length(q) - t.y;
22
+ }
23
+
24
+ float sdCapsule(in vec3 p, in vec3 a, in vec3 b, in float r) {
25
+ vec3 pa = p - a, ba = b - a;
26
+ float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
27
+ return length(pa - ba * h) - r;
28
+ }
29
+
30
+ float sdCone(in vec3 p, in vec2 c) { // c.x=height, c.y=radius
31
+ vec2 q = vec2(length(p.xz), p.y);
32
+ vec2 a = c - q;
33
+ vec2 b = q - c * vec2(1, -1);
34
+ float k = sign(c.y);
35
+ float d = min(dot(a, a), dot(b, b));
36
+ float s = max(k * (q.x * c.y - q.y * c.x), k * (q.y - c.y));
37
+ return sqrt(d) * sign(s);
38
+ }
39
+
40
+ float sdPlane(in vec3 p, in vec4 n) { // n.xyz is normal, n.w is offset
41
+ return dot(p, n.xyz) + n.w;
42
+ }
43
+
44
+ float sdHexPrism(in vec3 p, in vec2 h) { // h.x=radius, h.y=height
45
+ const vec3 k = vec3(-0.8660254, 0.5, 0.57735026);
46
+ p = abs(p);
47
+ p.xy -= 2.0 * min(dot(k.xy, p.xy), 0.0) * k.xy;
48
+ vec2 d = vec2(
49
+ length(p.xy - vec2(clamp(p.x, -k.z * h.x, k.z * h.x), h.x)) * sign(p.y - h.x),
50
+ p.z - h.y);
51
+ return min(max(d.x, d.y), 0.0) + length(max(d, 0.0));
52
+ }
53
+
54
+ float sdOctahedron(in vec3 p, in float s) {
55
+ p = abs(p);
56
+ return (p.x + p.y + p.z - s) * 0.57735027;
57
+ }
58
+
59
+ float sdEllipsoid(in vec3 p, in vec3 r) { // r is radii on each axis
60
+ float k0 = length(p / r);
61
+ float k1 = length(p / (r * r));
62
+ return k0 * (k0 - 1.0) / k1;
63
+ }
64
+
65
+ float sdCylinder(vec3 p, vec2 h) { // h.x=radius, h.y=half-height
66
+ vec2 d = abs(vec2(length(p.xz), p.y)) - h;
67
+ return min(max(d.x, d.y), 0.0) + length(max(d, 0.0));
68
+ }
69
+
70
+ // --- 2D Primitives (for extrusion or direct use if p.z=0) ---
71
+
72
+ float sdCircle(in vec2 p, in float r) {
73
+ return length(p) - r;
74
+ }
75
+
76
+ float sdRectangle(in vec2 p, in vec2 b) {
77
+ vec2 d = abs(p) - b;
78
+ return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
79
+ }
@@ -0,0 +1,46 @@
1
+ // --- Transformations ---
2
+
3
+ vec4 opTwist( in vec4 sdf_result, inout vec3 p, float k )
4
+ {
5
+ float c = cos(k*p.y);
6
+ float s = sin(k*p.y);
7
+ mat2 m = mat2(c,-s,s,c);
8
+ p.xz = m*p.xz;
9
+
10
+ return sdf_result;
11
+ }
12
+
13
+ vec3 opRotateX(vec3 p, float theta) {
14
+ float c = cos(theta);
15
+ float s = sin(theta);
16
+ return vec3(p.x, p.y * c - p.z * s, p.y * s + p.z * c);
17
+ }
18
+
19
+ vec3 opRotateY(vec3 p, float theta) {
20
+ float c = cos(theta);
21
+ float s = sin(theta);
22
+ return vec3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
23
+ }
24
+
25
+ vec3 opRotateZ(vec3 p, float theta) {
26
+ float c = cos(theta);
27
+ float s = sin(theta);
28
+ return vec3(p.x * c - p.y * s, p.x * s + p.y * c, p.z);
29
+ }
30
+
31
+
32
+ // --- Domain Repetition ---
33
+
34
+ vec3 opRepeat(vec3 p, vec3 c)
35
+ {
36
+ return mod(p + 0.5 * c, c) - 0.5 * c;
37
+ }
38
+
39
+ // --- Mirroring ---
40
+
41
+ vec3 opMirror(vec3 p, vec3 a) {
42
+ if (a.x > 0.5) p.x = abs(p.x);
43
+ if (a.y > 0.5) p.y = abs(p.y);
44
+ if (a.z > 0.5) p.z = abs(p.z);
45
+ return p;
46
+ }
sdforge/mesh.py ADDED
@@ -0,0 +1,95 @@
1
+ import numpy as np
2
+ import time
3
+ import struct
4
+ from skimage import measure
5
+
6
+ def _cartesian_product(*arrays):
7
+ la = len(arrays)
8
+ dtype = np.result_type(*arrays)
9
+ arr = np.empty([len(a) for a in arrays] + [la], dtype=dtype)
10
+ for i, a in enumerate(np.ix_(*arrays)):
11
+ arr[...,i] = a
12
+ return arr.reshape(-1, la)
13
+
14
+ def _write_binary_stl(path, points):
15
+ n = len(points) // 3
16
+ points = np.array(points, dtype='float32').reshape((-1, 3, 3))
17
+ normals = np.cross(points[:,1] - points[:,0], points[:,2] - points[:,0])
18
+ norm = np.linalg.norm(normals, axis=1).reshape((-1, 1))
19
+ normals /= np.where(norm == 0, 1, norm)
20
+
21
+ dtype = np.dtype([
22
+ ('normal', ('<f', 3)),
23
+ ('points', ('<f', (3, 3))),
24
+ ('attr', '<H'),
25
+ ])
26
+
27
+ a = np.zeros(n, dtype=dtype)
28
+ a['points'] = points
29
+ a['normal'] = normals
30
+
31
+ with open(path, 'wb') as fp:
32
+ fp.write(b'\x00' * 80)
33
+ fp.write(struct.pack('<I', n))
34
+ fp.write(a.tobytes())
35
+
36
+ def save(sdf_obj, path, bounds=((-1.5, -1.5, -1.5), (1.5, 1.5, 1.5)), samples=2**22, verbose=True):
37
+ """
38
+ Generates a mesh from an SDF object using the Marching Cubes algorithm and saves it to a file.
39
+ """
40
+ start_time = time.time()
41
+ if verbose:
42
+ print(f"INFO: Generating mesh for '{path}'...")
43
+
44
+ try:
45
+ sdf_callable = sdf_obj.to_callable()
46
+ except (NotImplementedError, ImportError) as e:
47
+ print(f"ERROR: Could not generate mesh. {e}")
48
+ return
49
+
50
+ volume = (bounds[1][0] - bounds[0][0]) * (bounds[1][1] - bounds[0][1]) * (bounds[1][2] - bounds[0][2])
51
+ step = (volume / samples) ** (1 / 3)
52
+
53
+ if verbose:
54
+ print(f" - Bounds: {bounds}")
55
+ print(f" - Target samples: {samples}")
56
+ print(f" - Voxel step size: {step:.4f}")
57
+
58
+ X = np.arange(bounds[0][0], bounds[1][0], step)
59
+ Y = np.arange(bounds[0][1], bounds[1][1], step)
60
+ Z = np.arange(bounds[0][2], bounds[1][2], step)
61
+
62
+ if verbose:
63
+ count = len(X)*len(Y)*len(Z)
64
+ print(f" - Grid dimensions: {len(X)} x {len(Y)} x {len(Z)} = {count} points")
65
+
66
+ # Create all grid points (float32 for downstream libs)
67
+ points_grid = _cartesian_product(X, Y, Z).astype('f4')
68
+
69
+ if verbose:
70
+ print(" - Evaluating SDF on grid...")
71
+
72
+ distances = sdf_callable(points_grid)
73
+ distances = np.array(distances, dtype='f4').reshape(len(X), len(Y), len(Z))
74
+
75
+ try:
76
+ verts, faces, _, _ = measure.marching_cubes(distances, level=0, spacing=(step, step, step))
77
+ except ValueError:
78
+ print("ERROR: Marching cubes failed. The surface may not intersect the specified bounds or the SDF evaluation returned invalid values.")
79
+ print(" Suggestions:")
80
+ print(" - Increase 'samples' to produce a finer voxel grid (e.g. samples=2**23 or 2**24).")
81
+ print(" - Expand 'bounds' so the object is definitely inside the volume.")
82
+ print(" - Verify your SDF callable returns finite numeric distances for all points.")
83
+ return
84
+
85
+ verts += np.array(bounds[0]) # Offset vertices to correct world position
86
+
87
+ if path.lower().endswith('.stl'):
88
+ _write_binary_stl(path, verts[faces])
89
+ else:
90
+ print(f"ERROR: Unsupported file format '{path}'. Only .stl is currently supported.")
91
+ return
92
+
93
+ elapsed = time.time() - start_time
94
+ if verbose:
95
+ print(f"SUCCESS: Mesh with {len(faces)} triangles saved to '{path}' in {elapsed:.2f}s.")
sdforge/render.py ADDED
@@ -0,0 +1,415 @@
1
+ import sys
2
+ import os
3
+ import time
4
+ from pathlib import Path
5
+ import importlib.util
6
+ import numpy as np
7
+ from watchdog.observers import Observer
8
+ from watchdog.events import FileSystemEventHandler
9
+
10
+ from .api import Camera, Light
11
+
12
+ # The maximum number of unique materials supported in a scene.
13
+ MAX_MATERIALS = 64
14
+
15
+ def _glsl_format(val):
16
+ """Formats a Python value for injection into a GLSL string."""
17
+ if isinstance(val, str):
18
+ return val
19
+ return f"{float(val)}"
20
+
21
+ def is_in_colab():
22
+ """Checks if the code is running in a Google Colab environment."""
23
+ return 'google.colab' in sys.modules
24
+
25
+ def get_glsl_content(filename: str) -> str:
26
+ """Reads the content of a GLSL file from the package."""
27
+ glsl_dir = Path(__file__).parent / 'glsl'
28
+ try:
29
+ with open(glsl_dir / filename, 'r') as f:
30
+ return f.read()
31
+ except FileNotFoundError:
32
+ print(f"ERROR: Could not find GLSL file: {filename}")
33
+ return ""
34
+
35
+ def assemble_shader_code(sdf_obj) -> str:
36
+ """Assembles the final GLSL scene function from an SDF object."""
37
+ # The Scene function now returns a vec4: (distance, material_id, 0, 0)
38
+ scene_glsl = sdf_obj.to_glsl()
39
+ inline_definitions = []
40
+ # Use a set to prevent duplicates, which cause redefinition errors
41
+ unique_defs = set(sdf_obj.get_glsl_definitions())
42
+ for d in unique_defs:
43
+ inline_definitions.append(d)
44
+ joined_defs = '\n'.join(inline_definitions)
45
+ return f"""
46
+ {joined_defs}
47
+ vec4 Scene(in vec3 p) {{ return {scene_glsl}; }}
48
+ """
49
+
50
+ class NativeRenderer:
51
+ """Handles the creation of a native window and renders the SDF."""
52
+
53
+ def __init__(self, sdf_obj, camera=None, lighting=None, watch=False, width=1280, height=720, record=None, bg_color=(0.1, 0.12, 0.15)):
54
+ self.sdf_obj = sdf_obj
55
+ self.camera = camera
56
+ self.lighting = lighting
57
+ self.watching = watch
58
+ self.width = width
59
+ self.height = height
60
+ self.record_path = record
61
+ self.bg_color = bg_color
62
+ self.window = None
63
+ self.ctx = None
64
+ self.program = None
65
+ self.vao = None
66
+ self.vbo = None
67
+ self.script_path = os.path.abspath(sys.argv[0])
68
+ self.reload_pending = False
69
+
70
+ def _init_window(self):
71
+ import glfw
72
+ if not glfw.init(): raise RuntimeError("Could not initialize GLFW")
73
+ glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3)
74
+ glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3)
75
+ glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)
76
+ glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True)
77
+ self.window = glfw.create_window(self.width, self.height, "SDF Forge Viewer", None, None)
78
+ if not self.window:
79
+ glfw.terminate()
80
+ raise RuntimeError("Could not create GLFW window")
81
+ glfw.make_context_current(self.window)
82
+
83
+ def _compile_shader(self):
84
+ import moderngl
85
+
86
+ materials = []
87
+ self.sdf_obj._collect_materials(materials)
88
+ if len(materials) > MAX_MATERIALS:
89
+ print(f"WARNING: Exceeded maximum of {MAX_MATERIALS} materials. Truncating.")
90
+ materials = materials[:MAX_MATERIALS]
91
+
92
+ material_struct_glsl = "struct MaterialInfo { vec3 color; };\n"
93
+ material_uniform_glsl = f"uniform MaterialInfo u_materials[{max(1, len(materials))}];\n"
94
+
95
+ # --- Light ---
96
+ light_pos_str = "ro"
97
+ ambient_strength_str = "0.1"
98
+ shadow_softness_str = "8.0"
99
+ ao_strength_str = "3.0"
100
+
101
+ if self.lighting:
102
+ if self.lighting.position:
103
+ pos = self.lighting.position
104
+ light_pos_str = f"vec3({_glsl_format(pos[0])}, {_glsl_format(pos[1])}, {_glsl_format(pos[2])})"
105
+ ambient_strength_str = _glsl_format(self.lighting.ambient_strength)
106
+ shadow_softness_str = _glsl_format(self.lighting.shadow_softness)
107
+ ao_strength_str = _glsl_format(self.lighting.ao_strength)
108
+
109
+ # --- Camera ---
110
+ camera_logic_glsl = ""
111
+ if self.camera:
112
+ pos = self.camera.position
113
+ pos_str = f"vec3({_glsl_format(pos[0])}, {_glsl_format(pos[1])}, {_glsl_format(pos[2])})"
114
+
115
+ target = self.camera.target
116
+ target_str = f"vec3({_glsl_format(target[0])}, {_glsl_format(target[1])}, {_glsl_format(target[2])})"
117
+
118
+ zoom_str = _glsl_format(self.camera.zoom)
119
+
120
+ camera_logic_glsl = f"cameraStatic(st, {pos_str}, {target_str}, {zoom_str}, ro, rd);"
121
+ else:
122
+ camera_logic_glsl = "cameraOrbit(st, u_mouse.xy, u_resolution, 1.0, ro, rd);"
123
+
124
+ scene_code = assemble_shader_code(self.sdf_obj)
125
+
126
+ full_fragment_shader = f"""
127
+ #version 330 core
128
+
129
+ uniform vec2 u_resolution;
130
+ uniform float u_time;
131
+ uniform vec4 u_mouse;
132
+ uniform vec3 u_bg_color;
133
+
134
+ {material_struct_glsl}
135
+ {material_uniform_glsl}
136
+
137
+ out vec4 f_color;
138
+
139
+ {get_glsl_content('sdf/primitives.glsl')}
140
+ {get_glsl_content('scene/camera.glsl')}
141
+ {get_glsl_content('scene/raymarching.glsl')}
142
+ {get_glsl_content('scene/lighting.glsl')}
143
+
144
+ {scene_code}
145
+
146
+ void main() {{
147
+ vec2 st = (2.0 * gl_FragCoord.xy - u_resolution.xy) / u_resolution.y;
148
+ vec3 ro, rd;
149
+ {camera_logic_glsl}
150
+
151
+ vec3 color = u_bg_color;
152
+ vec4 hit = raymarch(ro, rd);
153
+ float t = hit.x;
154
+ int material_id = int(hit.y);
155
+
156
+ if (t > 0.0) {{
157
+ vec3 p = ro + t * rd;
158
+ vec3 normal = estimateNormal(p);
159
+ vec3 lightPos = {light_pos_str};
160
+ vec3 lightDir = normalize(lightPos - p);
161
+
162
+ float diffuse = max(dot(normal, lightDir), {ambient_strength_str});
163
+ float shadow = softShadow(p + normal * 0.01, lightDir, {shadow_softness_str});
164
+ diffuse *= shadow;
165
+ float ao = ambientOcclusion(p, normal, {ao_strength_str});
166
+
167
+ vec3 material_color = vec3(0.8); // Default color if no material
168
+ if (material_id >= 0 && material_id < {len(materials)}) {{
169
+ material_color = u_materials[material_id].color;
170
+ }}
171
+
172
+ color = material_color * diffuse * ao;
173
+ }}
174
+ f_color = vec4(color, 1.0);
175
+ }}
176
+ """
177
+
178
+ vertex_shader = """
179
+ #version 330 core
180
+ in vec2 in_vert;
181
+ void main() { gl_Position = vec4(in_vert, 0.0, 1.0); }
182
+ """
183
+ try:
184
+ program = self.ctx.program(vertex_shader=vertex_shader, fragment_shader=full_fragment_shader)
185
+ print("INFO: Shader compiled successfully.")
186
+
187
+ # Upload material data
188
+ for i, mat in enumerate(materials):
189
+ program[f'u_materials[{i}].color'].value = mat.color
190
+
191
+ program['u_bg_color'].value = self.bg_color
192
+
193
+ return program
194
+ except Exception as e:
195
+ print(f"ERROR: Shader compilation failed. Keeping previous shader. Details:\n{e}")
196
+ return self.program
197
+
198
+ def _setup_gl(self):
199
+ import moderngl
200
+ self.ctx = moderngl.create_context()
201
+ self.program = self._compile_shader()
202
+ if self.program is None:
203
+ raise RuntimeError("Failed to compile initial shader. Cannot continue.")
204
+ vertices = np.array([-1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0], dtype='f4')
205
+ self.vbo = self.ctx.buffer(vertices)
206
+ self.vao = self.ctx.simple_vertex_array(self.program, self.vbo, 'in_vert')
207
+
208
+ def _reload_script(self):
209
+ print(f"INFO: Change detected in '{Path(self.script_path).name}'. Reloading...")
210
+ try:
211
+ spec = importlib.util.spec_from_file_location("user_script", self.script_path)
212
+ module = importlib.util.module_from_spec(spec)
213
+ spec.loader.exec_module(module)
214
+ if hasattr(module, 'main') and callable(module.main):
215
+ result = module.main()
216
+ new_sdf_obj, new_cam_obj, new_lighting_obj = None, None, None
217
+
218
+ if isinstance(result, tuple):
219
+ new_sdf_obj = result[0]
220
+ for item in result[1:]:
221
+ if isinstance(item, Camera):
222
+ new_cam_obj = item
223
+ elif isinstance(item, Light):
224
+ new_lighting_obj = item
225
+ else:
226
+ new_sdf_obj = result
227
+
228
+ if new_sdf_obj:
229
+ self.sdf_obj = new_sdf_obj
230
+ self.camera = new_cam_obj
231
+ self.lighting = new_lighting_obj
232
+ self.program = self._compile_shader()
233
+ self.vao = self.ctx.simple_vertex_array(self.program, self.vbo, 'in_vert')
234
+ else:
235
+ print("WARNING: No valid `main` function found. Cannot reload.")
236
+ except Exception as e:
237
+ print(f"ERROR: Failed to reload script: {e}")
238
+
239
+ def _start_watcher(self):
240
+ class ChangeHandler(FileSystemEventHandler):
241
+ def __init__(self, renderer_instance): self.renderer = renderer_instance
242
+ def on_modified(self, event):
243
+ if event.src_path == self.renderer.script_path:
244
+ self.renderer.reload_pending = True
245
+
246
+ observer = Observer()
247
+ observer.schedule(ChangeHandler(self), str(Path(self.script_path).parent), recursive=False)
248
+ observer.daemon = True
249
+ observer.start()
250
+ print(f"INFO: Watching '{Path(self.script_path).name}' for changes...")
251
+
252
+ def run(self):
253
+ import glfw
254
+ import moderngl
255
+
256
+ writer = None
257
+ if self.record_path:
258
+ try:
259
+ import imageio
260
+ fps = 30
261
+ writer = imageio.get_writer(self.record_path, fps=fps)
262
+ print(f"INFO: Recording video to '{self.record_path}' at {fps} FPS.")
263
+ except ImportError:
264
+ print("ERROR: 'imageio' is required for video recording.\nPlease install it via: pip install sdforge[record]")
265
+ self.record_path = None
266
+
267
+ self._init_window()
268
+ self._setup_gl()
269
+
270
+ if self.watching: self._start_watcher()
271
+
272
+ while not glfw.window_should_close(self.window):
273
+ if self.reload_pending:
274
+ self._reload_script()
275
+ self.reload_pending = False
276
+
277
+ width, height = glfw.get_framebuffer_size(self.window)
278
+ self.ctx.viewport = (0, 0, width, height)
279
+
280
+ try: self.program['u_resolution'].value = (width, height)
281
+ except KeyError: pass
282
+ try: self.program['u_time'].value = glfw.get_time()
283
+ except KeyError: pass
284
+ try:
285
+ mx, my = glfw.get_cursor_pos(self.window)
286
+ self.program['u_mouse'].value = (mx, height - my, 0, 0)
287
+ except KeyError: pass
288
+
289
+ self.vao.render(mode=moderngl.TRIANGLE_STRIP)
290
+
291
+ if writer:
292
+ frame_bytes = self.ctx.fbo.read(components=3, alignment=1)
293
+ frame_np = np.frombuffer(frame_bytes, dtype=np.uint8).reshape((height, width, 3))
294
+ writer.append_data(np.flipud(frame_np))
295
+
296
+ glfw.swap_buffers(self.window)
297
+ glfw.poll_events()
298
+
299
+ if writer: writer.close()
300
+ print("INFO: Viewer window closed.")
301
+ glfw.terminate()
302
+
303
+ def render(sdf_obj, camera=None, lighting=None, watch=True, record=None, bg_color=(0.1, 0.12, 0.15), **kwargs):
304
+ if is_in_colab():
305
+ from IPython.display import display, HTML
306
+
307
+ materials = []
308
+ sdf_obj._collect_materials(materials)
309
+ if len(materials) > MAX_MATERIALS:
310
+ materials = materials[:MAX_MATERIALS]
311
+
312
+ material_struct_glsl = "struct MaterialInfo { vec3 color; };\n"
313
+
314
+ colors_glsl = ", ".join([f"vec3({c.color[0]}, {c.color[1]}, {c.color[2]})" for c in materials])
315
+ material_array_glsl = f"const MaterialInfo u_materials[{max(1, len(materials))}] = MaterialInfo[]({colors_glsl});\n"
316
+
317
+ # --- Light ---
318
+ light_pos_str = "ro"
319
+ ambient_strength_str = "0.1"
320
+ shadow_softness_str = "8.0"
321
+ ao_strength_str = "3.0"
322
+
323
+ if lighting:
324
+ if lighting.position:
325
+ pos = lighting.position
326
+ light_pos_str = f"vec3({_glsl_format(pos[0])}, {_glsl_format(pos[1])}, {_glsl_format(pos[2])})"
327
+ ambient_strength_str = _glsl_format(lighting.ambient_strength)
328
+ shadow_softness_str = _glsl_format(lighting.shadow_softness)
329
+ ao_strength_str = _glsl_format(lighting.ao_strength)
330
+
331
+ # --- Camera ---
332
+ camera_logic_glsl = ""
333
+ if camera:
334
+ pos = camera.position
335
+ pos_str = f"vec3({_glsl_format(pos[0])}, {_glsl_format(pos[1])}, {_glsl_format(pos[2])})"
336
+ target = camera.target
337
+ target_str = f"vec3({_glsl_format(target[0])}, {_glsl_format(target[1])}, {_glsl_format(target[2])})"
338
+ zoom_str = _glsl_format(camera.zoom)
339
+ camera_logic_glsl = f"cameraStatic(st, {pos_str}, {target_str}, {zoom_str}, ro, rd);"
340
+ else:
341
+ camera_logic_glsl = "cameraOrbit(st, u_mouse.xy, u_resolution, 1.0, ro, rd);"
342
+
343
+ shader_code = assemble_shader_code(sdf_obj)
344
+ html_template = f"""
345
+ <!DOCTYPE html><html><head><title>SDF Forge Viewer</title>
346
+ <style>body{{margin:0;overflow:hidden}}canvas{{display:block}}</style></head>
347
+ <body><script type="importmap">{{"imports":{{"three":"https://unpkg.com/three@0.157.0/build/three.module.js"}}}}</script>
348
+ <script type="module">
349
+ import * as THREE from 'three';
350
+ const fragmentShader = `
351
+ varying vec2 vUv;
352
+ uniform float u_time;
353
+ uniform vec4 u_mouse;
354
+ #define u_resolution vec2(800.0, 600.0)
355
+ #define u_bg_color vec3({bg_color[0]}, {bg_color[1]}, {bg_color[2]})
356
+
357
+ {material_struct_glsl}
358
+ {material_array_glsl}
359
+
360
+ {get_glsl_content('sdf/primitives.glsl')}
361
+ {get_glsl_content('scene/camera.glsl')}
362
+ {get_glsl_content('scene/raymarching.glsl')}
363
+ {get_glsl_content('scene/lighting.glsl')}
364
+ {shader_code}
365
+ void main() {{
366
+ vec2 st = (2.0*vUv - 1.0) * vec2(u_resolution.x/u_resolution.y, 1.0);
367
+ vec3 ro, rd;
368
+ {camera_logic_glsl}
369
+ vec3 color = u_bg_color;
370
+ vec4 hit = raymarch(ro, rd);
371
+ float t = hit.x;
372
+ int material_id = int(hit.y);
373
+ if (t > 0.0) {{
374
+ vec3 p = ro + t * rd;
375
+ vec3 normal = estimateNormal(p);
376
+ vec3 lightPos = {light_pos_str};
377
+ vec3 lightDir = normalize(lightPos - p);
378
+ float diffuse = max(dot(normal, lightDir), {ambient_strength_str});
379
+ float shadow = softShadow(p+normal*0.01, lightDir, {shadow_softness_str});
380
+ diffuse *= shadow;
381
+ float ao = ambientOcclusion(p, normal, {ao_strength_str});
382
+ vec3 material_color = vec3(0.8);
383
+ if (material_id >= 0 && material_id < {len(materials)}) {{
384
+ material_color = u_materials[material_id].color;
385
+ }}
386
+ color = material_color * diffuse * ao;
387
+ }}
388
+ gl_FragColor = vec4(color, 1.0);
389
+ }}
390
+ `;
391
+ const scene = new THREE.Scene();
392
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
393
+ const renderer = new THREE.WebGLRenderer({{antialias: true}});
394
+ document.body.appendChild(renderer.domElement);
395
+ const uniforms = {{u_time:{{value:0}}, u_mouse:{{value:new THREE.Vector4()}}}};
396
+ const material = new THREE.ShaderMaterial({{vertexShader:`varying vec2 vUv; void main(){{vUv=uv;gl_Position=vec4(position,1.0);}}`, fragmentShader, uniforms}});
397
+ scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2,2), material));
398
+ renderer.setSize(800,600);
399
+ document.addEventListener('mousemove', e=>{{uniforms.u_mouse.value.x=e.clientX;uniforms.u_mouse.value.y=600-e.clientY;}});
400
+ function animate(t){{requestAnimationFrame(animate);uniforms.u_time.value=t*0.001;renderer.render(scene,camera);}}
401
+ animate();
402
+ </script></body></html>
403
+ """
404
+ escaped_html = html_template.replace('"', "&quot;")
405
+ display(HTML(f'<iframe srcdoc="{escaped_html}" width="800" height="600" style="border:1px solid #ccc"></iframe>'))
406
+ return
407
+
408
+ try:
409
+ import moderngl, glfw
410
+ except ImportError:
411
+ print("ERROR: Live rendering requires 'moderngl' and 'glfw'.\nPlease install them via: pip install sdforge")
412
+ return
413
+
414
+ renderer = NativeRenderer(sdf_obj, camera=camera, lighting=lighting, watch=watch, record=record, bg_color=bg_color, **kwargs)
415
+ renderer.run()