diff --git a/.gitignore b/.gitignore index 78e70a2..a7a800e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ /native/output/ /coverage/ *.tsbuildinfo +__pycache__/ +*.pyc # Credentials and machine-local state .env* diff --git a/README.md b/README.md index 0937e2d..1ab3ffe 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ Projects remain on your computer. Rendering, physics and scripts run in the brow - Scene hierarchy, component inspector, transform gizmos, command search and undo/redo. - Multiple scenes, reusable prefabs, procedural meshes, extrusion and lathe tools. -- GLB and self-contained glTF import, PBR materials, skeletons and animation clips. +- GLB, glTF resource bundles and ZIP import, PBR materials, skeletons, morph targets, cameras, lights and animation clips. +- Local Blender conversion with sampled procedural-material baking and animated material/UV properties. See the [compatibility matrix](docs/BLENDER.md). - Rapier rigid bodies, colliders and a character controller; orbit and first-person cameras. - JavaScript behaviours with editable properties, worker execution and runtime diagnostics. - A shared document and revision-checked transactions for both the editor and MCP. diff --git a/blender/export_forma.py b/blender/export_forma.py new file mode 100644 index 0000000..2eb9018 --- /dev/null +++ b/blender/export_forma.py @@ -0,0 +1,476 @@ +"""Run in a separate Blender background process; never saves the input .blend. +Exports the scene timeline, with optional per-frame PBR texture atlas baking. +""" +import argparse +import fnmatch +import json +import math +import os +from pathlib import Path +import struct +import sys +import tempfile + +import bpy +import numpy as np + + +def deselect_all(): + # Blender's operator skips hidden objects that can retain a saved selection. + for obj in bpy.context.view_layer.objects: + obj.select_set(False) + + +def arguments(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', required=True) + parser.add_argument('--scene') + parser.add_argument('--objects', action='append', help='Object name glob; repeat to select a subset') + parser.add_argument('--bake-materials', action='store_true') + parser.add_argument('--resolution', type=int, default=128) + parser.add_argument('--samples', type=int, default=8) + parser.add_argument('--lighting', choices=['compat', 'spec'], default='compat', help='Unitless lighting for Forma, or glTF physical light units') + parser.add_argument('--start-frame', type=int) + parser.add_argument('--end-frame', type=int) + parser.add_argument('--frame-step', type=int, default=1) + parser.add_argument('--allow-lossy', action='store_true', help='Permit features identified in the export report as unsupported') + return parser.parse_args(sys.argv[sys.argv.index('--') + 1:]) + + +def tree_nodes(tree, seen=None): + seen = set() if seen is None else seen + if not tree or tree.as_pointer() in seen: + return [] + seen.add(tree.as_pointer()) + result = list(tree.nodes) + for node in tree.nodes: + if node.type == 'GROUP': + result += tree_nodes(node.node_tree, seen) + return result + + +def animated(material): + for data in [material, material.node_tree] + [n.node_tree for n in tree_nodes(material.node_tree) if n.type == 'GROUP']: + anim = getattr(data, 'animation_data', None) + if anim and (anim.action or anim.drivers or anim.nla_tracks): + return True + return any(n.type == 'TEX_IMAGE' and n.image and n.image.source in {'MOVIE', 'SEQUENCE'} for n in tree_nodes(material.node_tree)) + + +def report_scene(objects, bake): + warnings = [] + needs_bake = set() + unsupported = [] + direct = {'ShaderNodeBsdfPrincipled', 'ShaderNodeOutputMaterial', 'ShaderNodeTexImage', 'ShaderNodeNormalMap', 'ShaderNodeUVMap', 'ShaderNodeMapping', 'ShaderNodeSeparateColor', 'ShaderNodeCombineColor', 'ShaderNodeRGB', 'ShaderNodeValue'} + for obj in objects: + if obj.type not in {'MESH', 'CURVE', 'FONT', 'SURFACE', 'EMPTY', 'ARMATURE', 'CAMERA', 'LIGHT'}: + unsupported.append(f'{obj.name}: object type {obj.type} needs conversion/render baking') + if obj.type == 'LIGHT' and obj.data.type == 'AREA': + unsupported.append(f'{obj.name}: area light is approximated by a point light') + for modifier in obj.modifiers: + if modifier.type in {'FLUID', 'CLOTH', 'SOFT_BODY', 'PARTICLE_SYSTEM', 'DYNAMIC_PAINT', 'NODES'}: + unsupported.append(f'{obj.name}: {modifier.type} is exported as evaluated geometry at the starting frame, not a simulation') + if obj.type == 'MESH' and obj.data.shape_keys and modifier.type != 'ARMATURE': + unsupported.append(f'{obj.name}: {modifier.type} cannot be applied while preserving shape keys; modifier is omitted') + for data in (obj, obj.data): + animation = getattr(data, 'animation_data', None) + if animation and animation.drivers: + warnings.append(f'{obj.name}: drivers are sampled with automatic Python execution disabled; inspect complex driver expressions') + for slot in obj.material_slots: + material = slot.material + if not material: + continue + nodes = tree_nodes(material.node_tree) + types = {n.bl_idname for n in nodes} + if types - direct or animated(material): + needs_bake.add(material.name) + if any('Volume' in t for t in types): + unsupported.append(f'{material.name}: volumetric shading cannot be represented by a glTF surface; use engine fog or render baking') + if types & {'ShaderNodeLayerWeight', 'ShaderNodeFresnel', 'ShaderNodeLightPath', 'ShaderNodeCameraData'}: + unsupported.append(f'{material.name}: view-dependent nodes are frozen by UV baking; inspect the result from different angles') + if any(n.type == 'OUTPUT_MATERIAL' and n.inputs['Displacement'].is_linked for n in nodes): + unsupported.append(f'{material.name}: displacement needs evaluated geometry; the surface bake does not displace vertices') + if not bake and material.name in needs_bake: + warnings.append(f'{material.name}: procedural/animated inputs may need --bake-materials; only glTF-compatible property animation transfers directly') + if bake: + for node in nodes: + if node.type == 'BSDF_PRINCIPLED': + for name in ('Transmission Weight', 'Coat Weight', 'Sheen Weight', 'Subsurface Weight', 'Thin Film Thickness', 'Anisotropic IOR Level'): + socket = node.inputs.get(name) + if socket and (socket.is_linked or socket.default_value != 0): + unsupported.append(f'{material.name}: baked PBR does not preserve {name}; use native glTF for compatible inputs') + return sorted(set(warnings)), sorted(set(unsupported)), sorted(needs_bake) + + +def socket_source(material): + tree = material.node_tree + output = next((n for n in tree.nodes if n.type == 'OUTPUT_MATERIAL' and n.is_active_output), None) + if not output or not output.inputs['Surface'].is_linked: + raise RuntimeError(f'{material.name}: no surface shader to bake') + surface = output.inputs['Surface'].links[0].from_node + alpha = None + if surface.type == 'MIX_SHADER': + links = [surface.inputs[i].links[0].from_node if surface.inputs[i].is_linked else None for i in (1, 2)] + transparent = [i for i, n in enumerate(links) if n and n.type == 'BSDF_TRANSPARENT'] + if len(transparent) == 1: + transparent_index = transparent[0] + alpha = surface.inputs[0] + if transparent_index == 1: + inverse = tree.nodes.new('ShaderNodeMath') + inverse.operation = 'SUBTRACT' + inverse.inputs[0].default_value = 1 + copy_socket(tree, alpha, inverse.inputs[1]) + alpha = inverse.outputs[0] + surface = links[1 - transparent_index] + if surface and surface.type == 'BSDF_PRINCIPLED': + return output, {'color': surface.inputs['Base Color'], 'alpha': alpha or surface.inputs['Alpha'], 'roughness': surface.inputs['Roughness'], 'metallic': surface.inputs['Metallic'], 'emission': surface.inputs['Emission Color'], 'strength': surface.inputs['Emission Strength']}, False + if surface and surface.type == 'EMISSION': + return output, {'color': surface.inputs['Color'], 'alpha': alpha, 'roughness': None, 'metallic': None, 'emission': surface.inputs['Color'], 'strength': surface.inputs['Strength']}, True + raise RuntimeError(f'{material.name}: bake requires a Principled or Emission surface (optionally mixed with Transparent)') + + +def copy_socket(tree, source, target, default=0): + if source is None: + value = default + elif source.is_output: + tree.links.new(source, target) + return + elif source.is_linked: + tree.links.new(source.links[0].from_socket, target) + return + else: + value = source.default_value + try: + target.default_value = value + except (TypeError, ValueError): + if isinstance(value, (int, float)): + target.default_value = (value, value, value, 1) + else: + target.default_value = float(value[0]) + + +def image_pixels(image, resolution): + pixels = np.empty(resolution * resolution * 4, dtype=np.float32) + image.pixels.foreach_get(pixels) + return pixels.reshape((resolution, resolution, 4)) + + +def bake_object(obj, scene, frames, options, temporary, warnings): + # Each object gets its own material because UV/object coordinates can differ. + original_slots = [slot.material for slot in obj.material_slots] + if not original_slots: + return None + obj.data = obj.data.copy() + for i, material in enumerate(original_slots): + if material: + obj.material_slots[i].material = material.copy() + if not obj.data.uv_layers: + deselect_all() + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.uv.smart_project(island_margin=.035) + bpy.ops.object.mode_set(mode='OBJECT') + states = [] + for material in [slot.material for slot in obj.material_slots if slot.material]: + if not material.use_nodes: + material.use_nodes = True + output, sockets, unlit = socket_source(material) + tree = material.node_tree + original = output.inputs['Surface'].links[0].from_socket + emission = tree.nodes.new('ShaderNodeEmission') + image_node = tree.nodes.new('ShaderNodeTexImage') + tree.nodes.active = image_node + states.append((tree, output, original, emission, image_node, sockets, unlit)) + if not states: + return None + sample_frames = frames if any(animated(m) for m in original_slots if m) else frames[:1] + size = options.resolution + pad = 2 + cell = size + 2 * pad + columns = math.ceil(math.sqrt(len(sample_frames))) + rows = math.ceil(len(sample_frames) / columns) + width, height = columns * cell, rows * cell + if max(width, height) > 8192: + raise RuntimeError('Texture atlas exceeds 8192px; increase --frame-step or reduce --resolution') + atlases = {key: np.zeros((height, width, 4), dtype=np.float32) for key in ('color', 'orm', 'normal', 'emission')} + image = bpy.data.images.new('FormaBakeTarget', width=size, height=size, alpha=True, float_buffer=True) + image.colorspace_settings.name = 'Non-Color' + for state in states: + state[4].image = image + deselect_all() + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + try: + for index, frame in enumerate(sample_frames): + scene.frame_set(frame) + for tree, *_ in states: + if tree.animation_data: + for curve in tree.animation_data.drivers: + if not curve.driver.is_valid: + raise RuntimeError(f'{obj.name}: invalid/disabled driver {curve.data_path}; prepare its animation in Blender before exporting') + channels = {} + for channel in ('color', 'alpha', 'roughness', 'metallic', 'emission', 'normal'): + for tree, output, original, emission, image_node, sockets, unlit in states: + tree.nodes.active = image_node + for link in list(output.inputs['Surface'].links): + tree.links.remove(link) + if channel == 'normal': + tree.links.new(original, output.inputs['Surface']) + else: + for input_socket in emission.inputs: + for link in list(input_socket.links): + tree.links.remove(link) + defaults = {'alpha': 1, 'roughness': .5, 'metallic': 0, 'emission': 0, 'color': .8} + copy_socket(tree, sockets.get(channel), emission.inputs['Color'], defaults[channel]) + if unlit and channel == 'color': + for link in list(emission.inputs['Color'].links): + tree.links.remove(link) + emission.inputs['Color'].default_value = (0, 0, 0, 1) + if channel == 'emission': + copy_socket(tree, sockets.get('strength'), emission.inputs['Strength'], 1) + else: + emission.inputs['Strength'].default_value = 1 + tree.links.new(emission.outputs[0], output.inputs['Surface']) + bpy.context.view_layer.update() + bpy.ops.object.bake(type='NORMAL' if channel == 'normal' else 'EMIT', use_clear=True, margin=8, normal_space='TANGENT') + channels[channel] = image_pixels(image, size).copy() + channels['color'][:, :, 3] = np.clip(channels['alpha'][:, :, 0], 0, 1) + orm = np.ones((size, size, 4), dtype=np.float32) + orm[:, :, 1] = channels['roughness'][:, :, 0] + orm[:, :, 2] = channels['metallic'][:, :, 0] + channels['orm'] = orm + y, x = (index // columns) * cell, (index % columns) * cell + for channel, atlas in atlases.items(): + atlas[y:y+cell, x:x+cell, :] = np.pad(channels[channel], ((pad, pad), (pad, pad), (0, 0)), mode='edge') + print(f'FORMA_BAKE {obj.name} {index+1}/{len(sample_frames)}', flush=True) + finally: + for tree, output, original, emission, image_node, sockets, unlit in states: + for link in list(output.inputs['Surface'].links): + tree.links.remove(link) + tree.links.new(original, output.inputs['Surface']) + bpy.data.images.remove(image) + strength = max(1.0, float(np.nanmax(atlases['emission'][:, :, :3]))) + atlases['emission'][:, :, :3] /= strength + material = bpy.data.materials.new('FormaBake_' + obj.name) + material.use_nodes = True + tree = material.node_tree + principled = tree.nodes.get('Principled BSDF') + if all(state[6] for state in states): + principled.inputs['Specular IOR Level'].default_value = 0 + texture_nodes = {} + for channel, pixels in atlases.items(): + pixels = np.nan_to_num(np.clip(pixels, 0, 1), nan=0, posinf=1, neginf=0) + atlas_image = bpy.data.images.new(material.name + '_' + channel, width=width, height=height, alpha=True) + atlas_image.colorspace_settings.name = 'sRGB' if channel in {'color', 'emission'} else 'Non-Color' + atlas_image.pixels.foreach_set(pixels.ravel()) + atlas_image.filepath_raw = str(Path(temporary) / (str(atlas_image.as_pointer()) + '.png')) + atlas_image.file_format = 'PNG' + atlas_image.save() + atlas_image.pack() + tex = tree.nodes.new('ShaderNodeTexImage') + tex.image = atlas_image + texture_nodes[channel] = tex + tree.links.new(texture_nodes['color'].outputs['Color'], principled.inputs['Base Color']) + tree.links.new(texture_nodes['color'].outputs['Alpha'], principled.inputs['Alpha']) + separate = tree.nodes.new('ShaderNodeSeparateColor') + tree.links.new(texture_nodes['orm'].outputs['Color'], separate.inputs[0]) + tree.links.new(separate.outputs['Green'], principled.inputs['Roughness']) + tree.links.new(separate.outputs['Blue'], principled.inputs['Metallic']) + normal = tree.nodes.new('ShaderNodeNormalMap') + tree.links.new(texture_nodes['normal'].outputs['Color'], normal.inputs['Color']) + tree.links.new(normal.outputs['Normal'], principled.inputs['Normal']) + tree.links.new(texture_nodes['emission'].outputs['Color'], principled.inputs['Emission Color']) + principled.inputs['Emission Strength'].default_value = strength + obj.data.materials.clear() + obj.data.materials.append(material) + for polygon in obj.data.polygons: + polygon.material_index = 0 + return {'material': material.name, 'frames': sample_frames, 'scale': [size / width, size / height], 'offsets': [[((i % columns) * cell + pad) / width, ((rows - 1 - i // columns) * cell + pad) / height] for i in range(len(sample_frames))], 'alpha': any(np.min(atlases['color'][(i // columns)*cell:(i // columns+1)*cell, (i % columns)*cell:(i % columns+1)*cell, 3]) < .999 for i in range(len(sample_frames))), 'emissionStrength': strength} + + +def read_glb(filename): + data = Path(filename).read_bytes() + length = struct.unpack_from(' 1024 or options.frame_step < 1 or not 1 <= options.samples <= 256: + raise RuntimeError('Resolution must be 16–1024, frame step >= 1, samples 1–256') + scene = bpy.data.scenes.get(options.scene) if options.scene else bpy.context.scene + if scene is None: + raise RuntimeError('Scene not found') + bpy.context.window.scene = scene + scene.frame_start = options.start_frame if options.start_frame is not None else scene.frame_start + scene.frame_end = options.end_frame if options.end_frame is not None else scene.frame_end + if scene.frame_end < scene.frame_start: + raise RuntimeError('End frame precedes start frame') + frames = list(range(scene.frame_start, scene.frame_end + 1, options.frame_step)) + if options.bake_materials and len(frames) > 256: + raise RuntimeError('At most 256 baked frames; increase --frame-step') + scene.frame_set(scene.frame_start) + objects = [o for o in scene.objects if not o.hide_render and (not options.objects or any(fnmatch.fnmatchcase(o.name, pattern) for pattern in options.objects))] + if not objects: + raise RuntimeError('No objects matched the export selection') + warnings, unsupported, needs_bake = report_scene(objects, options.bake_materials) + report = {'version': 1, 'blender': bpy.app.version_string, 'scene': scene.name, 'objects': len(objects), 'bakedMaterials': [], 'warnings': warnings + unsupported, 'frames': [scene.frame_start, scene.frame_end], 'frameStep': options.frame_step, 'lighting': options.lighting} + output = Path(options.output).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + report_path = output.with_suffix('.report.json') + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)) + if unsupported and not options.allow_lossy: + raise RuntimeError('Unsupported features found; inspect ' + str(report_path) + '. Use --allow-lossy only to accept the listed approximations.') + for obj in objects: + if obj.type == 'LIGHT' and obj.data.type == 'AREA': + obj.data = obj.data.copy() + obj.data.type = 'POINT' + deselect_all() + for obj in objects: + obj.hide_set(False) + obj.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + object_names = [obj.name for obj in objects] + curves = [obj for obj in objects if obj.type in {'CURVE', 'FONT', 'SURFACE'}] + for obj in objects: + obj.select_set(obj in curves) + if curves: + bpy.context.view_layer.objects.active = curves[0] + bpy.ops.object.convert(target='MESH') + # Conversion normally preserves object identity and names. + objects = [bpy.data.objects.get(name) for name in object_names] + objects = [obj for obj in objects if obj] + for obj in objects: + if obj.type != 'MESH' or obj.data.shape_keys: + continue + deselect_all() + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + obj.data = obj.data.copy() + for modifier in list(obj.modifiers): + if modifier.type == 'ARMATURE': + continue + if not modifier.show_render: + obj.modifiers.remove(modifier) + continue + modifier.show_viewport = True + result = bpy.ops.object.modifier_apply(modifier=modifier.name) + if 'FINISHED' not in result: + raise RuntimeError(f'{obj.name}: could not apply modifier {modifier.name}') + if options.bake_materials: + modifier = obj.modifiers.new('Forma triangulation', 'TRIANGULATE') + bpy.ops.object.modifier_apply(modifier=modifier.name) + with tempfile.TemporaryDirectory(prefix='forma-bake-') as temporary: + bakes = [] + if options.bake_materials: + scene.render.engine = 'CYCLES' + scene.cycles.device = 'CPU' + scene.cycles.samples = options.samples + for obj in objects: + if obj.type != 'MESH' or not any(s.material and s.material.name in needs_bake for s in obj.material_slots): + continue + if any('Volume' in n.bl_idname for slot in obj.material_slots if slot.material for n in tree_nodes(slot.material.node_tree)): + continue + baked = bake_object(obj, scene, frames, options, temporary, warnings) + if baked: + bakes.append(baked) + scene.frame_set(scene.frame_start) + deselect_all() + for obj in objects: + obj.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + properties = bpy.ops.export_scene.gltf.get_rna_type().properties + required = ['export_pointer_animation', 'export_animation_mode'] + if any(name not in properties for name in required): + raise RuntimeError('Install a Blender version with glTF Animation Pointer export support') + settings = dict(filepath=str(output), export_format='GLB', use_selection=True, use_active_scene=True, export_animations=True, export_animation_mode='SCENE', export_pointer_animation=True, export_force_sampling=True, export_frame_range=True, export_frame_step=options.frame_step, export_skins=True, export_morph=True, export_morph_animation=True, export_cameras=True, export_lights=True, export_extras=True, export_texcoords=True, export_normals=True, export_tangents=True, export_anim_scene_split_object=False, export_convert_animation_pointer=True, export_bake_animation=True, export_gn_mesh=True, export_optimize_animation_size=False) + settings['export_import_convert_lighting_mode'] = options.lighting.upper() + bpy.ops.export_scene.gltf(**{key: value for key, value in settings.items() if key in properties}) + doc, binary = read_glb(output) + attach_atlas_animations(doc, binary, bakes, scene) + report['bakedMaterials'] = [{'material': b['material'], 'frames': len(b['frames'])} for b in bakes] + report['warnings'] = sorted(set(report['warnings'] + warnings)) + active_camera = next((doc['cameras'][node['camera']].get('name') for node in doc.get('nodes', []) if scene.camera and node.get('name') == scene.camera.name and 'camera' in node), None) + doc.setdefault('extras', {})['forma'] = {'version': 1, 'warnings': report['warnings'], 'source': 'Blender', 'activeCamera': active_camera, 'fps': scene.render.fps / scene.render.fps_base} + write_glb(output, doc, binary) + report['bytes'] = output.stat().st_size + report['clips'] = [animation.get('name') for animation in doc.get('animations', [])] + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)) + print('FORMA_EXPORT ' + json.dumps(report, ensure_ascii=False), flush=True) + + +if __name__ == '__main__': + main() diff --git a/docs/2_5D_PRESENTATION.md b/docs/2_5D_PRESENTATION.md index 6f572ef..3af5ecc 100644 --- a/docs/2_5D_PRESENTATION.md +++ b/docs/2_5D_PRESENTATION.md @@ -6,7 +6,7 @@ Forma supports fixed orthographic scenes that combine 3D geometry and transparen An entity can have `camera: { mode: "fixed", lookAt: [0, 0, 0], projection: "orthographic", orthoWidth: 24, aspect: 16 / 9 }`. The camera uses the entity's world position, faces `lookAt`, and preserves the specified aspect ratio with letterboxing. Omit `aspect` to fill the current viewport. The inspector exposes fixed mode, projection, width and look-at position. Perspective and follow/first-person cameras remain supported. -`settings.rendering` accepts `toneMapping`, `exposure` and `contrast`. Disabling tone mapping is useful for artwork that already contains lighting. Generated mesh materials accept `unlit`, `alpha` (0–1) and `doubleSided`; scripts can update them through `api.patch`. Imported GLB materials retain their authoring settings unless a material override is requested. +`settings.rendering` accepts `toneMapping`, `exposure`, `contrast` and `defaultLights`. Set `defaultLights: false` to use only authored lights, for example in an imported Blender scene. Disabling tone mapping is useful for artwork that already contains lighting. Generated mesh materials accept `unlit`, `alpha` (0–1) and `doubleSided`; scripts can update them through `api.patch`. Imported GLB materials retain their authoring settings unless a material override is requested. ## Controls diff --git a/docs/BLENDER.md b/docs/BLENDER.md index e9a530a..e223ac4 100644 --- a/docs/BLENDER.md +++ b/docs/BLENDER.md @@ -1,15 +1,76 @@ -# Blender and model import +# Blender → Forma -1. Check the model's scale, armature, deformations and animation names. -2. Export **glTF 2.0 → GLB** so geometry and textures travel in one file. -3. Include skinning and the desired animation actions/NLA tracks in the export settings. -4. Use **Импорт GLB** in Forma or drag the file into the editor. Import errors appear in the console. -5. Preview imported animation clips from the animation component. Choose clip names in your project scripts. -6. Add colliders and rigid-body or character components as needed. A model with feet at `y = 0` often needs an upward collider offset. -7. Attach a behaviour to add movement or other interactions. A model alone does not provide game logic. +Forma accepts **GLB**, **glTF with companion files**, and **ZIP containing one GLB/glTF scene and its resources**. Native `.blend` files are converted locally by Blender; the browser does not execute Blender or its node graphs. -Forma imports glTF PBR materials, model textures, skeletons and animation clips. Blender procedural materials and geometry-node workflows do not transfer directly; bake or apply them before export. +Use **Импорт модели** to add an asset to the current scene. Select a glTF and its adjacent `.bin`/images together, or use ZIP to preserve subdirectories. **Открыть сцену Blender** opens a model as a new project, selects the converter's active camera (or the first imported camera) and autoplays its first timeline on Play. If lights are present, Forma's default sun and ambient light are disabled. Undo restores the preceding project. Cameras can be selected in the model entity's Camera component. Internal glTF nodes remain inside the model entity rather than becoming individually editable Forma entities. -Self-contained `.gltf` files with data URIs are supported. External `.bin` and texture file sets are not assembled by the editor; GLB is the simplest portable path. Draco, Meshopt and KTX2-compressed assets are rejected by the current importer to avoid external decoder requirements. +## Direct export -Blender MCP is optional and is not installed by this repository. Forma's `arena` and `character` generators create editable hierarchies; `extrude`, `lathe` and `mesh_create` provide geometry tools without Blender. The compound character generator does not create a rig, and no neural text-to-3D model is bundled. +Requires a Blender glTF exporter with `Animation Pointer` and `Scene` animation support. The integration test was run with **Blender 5.2.1 LTS**. Older versions without those options fail with a diagnostic. + +```bash +npm run blender:export -- /path/to/Scene.blend --output ./work/Scene.glb +``` + +Set `FORMA_BLENDER` or pass `--blender /path/to/blender` if Blender is not on PATH. The command launches a separate background process, disables automatic Python execution and never saves the source `.blend` or touches the open Blender session. + +The converter exports the selected Blender scene and its frame range, converts curves/text/surfaces into meshes, applies render-enabled mesh modifiers when there are no shape keys, retains armatures and shape keys, embeds textures, and exports cameras, punctual lights, extras and supported property animation. `--scene NAME` chooses a scene; repeated `--objects 'Name*'` options restrict the objects. Include the rig and other dependencies when exporting a subset. + +The converter defaults to Blender's **Unitless** lighting mode (`--lighting compat`), matching Forma's ordinary exposure and avoiding overexposed physical lamps. `--lighting spec` preserves physical glTF light units and requires suitable runtime exposure. When using Blender's own exporter, choose Unitless lighting for the same result. + +The exporter emits one sampled scene timeline. It is not an Action/NLA clip library exporter. Alternatively, use Blender's own GLB export to retain separately named Actions, enable Animation Pointer for supported material properties, then select the desired autoplay clip in Forma's Animator component. + +## Animated procedural materials + +```bash +npm run blender:export -- /path/to/Scene.blend \ + --output ./work/Scene.glb --bake-materials \ + --resolution 128 --samples 8 \ + --start-frame 1 --end-frame 240 --frame-step 4 +``` + +The baker evaluates the material in Cycles at each sampled frame, including supported drivers, image sequences/movie frames and procedural inputs such as Noise, Voronoi, ColorRamp and Bump. It creates per-object UV atlases for base color/alpha, metallic/roughness, tangent-space normals and emission. It adds STEP animation of `KHR_texture_transform` offsets through `KHR_animation_pointer`. Forma plays these animations with the model timeline; pause also pauses materials. + +Supported bake surfaces are Principled BSDF and Emission, optionally mixed with one Transparent shader. Existing UVs are used; meshes without UVs get a smart unwrap. Existing overlapping UV islands may produce artifacts and should be unwrapped before baking. Node groups feeding these inputs work through Cycles; arbitrary groups/mixes that produce a surface closure need conversion to a supported surface first. Unsupported closures fail explicitly. + +`--resolution` is 16–1024 pixels per frame; `--samples` is 1–256; at most 256 sampled frames and an 8192px atlas edge are allowed. Two-pixel borders and linear filtering without mipmaps prevent adjacent frame bleeding. Smaller frame steps produce smoother animation and larger files. Static procedural materials use one atlas cell. Animated geometry can change a procedural material's appearance even without material drivers; add material animation or bake it explicitly as an animated source before relying on that case. + +## Compatibility and limits + +| Blender feature | Transfer | +| --- | --- | +| Mesh hierarchy, UVs, textures, transforms, extras | glTF model hierarchy; extras retained | +| Armature, skinning, shape keys | Native glTF, including animation; no retargeting | +| Curves, text, surfaces | Converted to mesh at export | +| Compatible PBR, transparency, normal/emission textures | Native glTF materials | +| Supported material, UV, camera and light keyframes | `KHR_animation_pointer`; independent targets per model instance | +| Noise/Voronoi/ColorRamp/Bump and driver-driven surface inputs | Sampled texture baking for supported surface closures | +| Movie/image sequence inputs | Sampled into atlases; no audio or live video playback | +| Point, spot, sun lights; perspective/orthographic cameras | Native glTF; rendering differs from Cycles/EEVEE | +| Area lights | Optional point-light approximation, listed in report | +| Geometry Nodes and physics simulations | Evaluated start-frame geometry; no live simulation/cache playback | +| Modifiers on meshes with shape keys | Preserved shape keys; unsupported modifier application is reported | +| Volume shaders, World node graphs, compositor, render effects | No equivalent full transfer; use Forma fog/lighting or render baking | +| View-dependent shading, true displacement, baked coat/transmission/thin-film etc. | Reported limitations; base PBR baking cannot reproduce them exactly | +| Arbitrary OSL/Python nodes, custom Python drivers, Blender scripts | No runtime execution in Forma; complex disabled drivers need authoring-side preparation | + +The converter writes `Scene.report.json` and embeds warnings in the GLB. Recognized unsupported features stop export by default. Inspect the report and use `--allow-lossy` to accept its listed approximations. This flag does not turn unsupported features into equivalents, and unsupported shader closures can still fail. Always inspect Blender's own exporter log too. A successful export is not a guarantee of pixel-identical Cycles/EEVEE rendering. + +Current portable model limit: **25 MiB**, ZIP expansion budget: **80 MiB**. Split large scenes or reduce texture/frame resolution. External resources must be supplied locally inside the import bundle/project directory; network URLs and escaping paths are rejected. Draco, Meshopt and KTX2 are rejected because offline decoders are not bundled. + +## MCP + +`asset_import_glb` keeps its name for compatibility and accepts `.glb`, `.gltf`, or `.zip`. Supply `base64` or a path inside the Forma project directory. `instantiate: true` adds a model and Animator; `asScene: true` performs an undoable project replacement using imported cameras/lights. The browser editor has the same import pipeline. + +## Verification + +```bash +npm run build +npm run typecheck +npm test +FORMA_BLENDER=/path/to/blender node --import tsx --test tests/blender.test.ts +``` + +The optional Blender integration test generates its own scene, exports driver-baked textures, a curve, shape keys, a camera and a light, and verifies that the source file is unchanged. Runtime tests exercise actual material/camera/light/morph animation and instance isolation. The shipped repository contains no personal Blender projects or baked game assets. + +Reference: [Blender glTF exporter manual](https://docs.blender.org/manual/en/dev/addons/scene_gltf2.html). diff --git a/docs/HOSTED_EDITOR.md b/docs/HOSTED_EDITOR.md index 12b759c..8727e85 100644 --- a/docs/HOSTED_EDITOR.md +++ b/docs/HOSTED_EDITOR.md @@ -9,9 +9,9 @@ npm run build node --import tsx scripts/export-editor.ts PROJECT.forma OUTPUT_DIRECTORY ``` -Serve OUTPUT_DIRECTORY over HTTP. The entry point is index.html. It loads project.forma.json and starts Editor with `initialProject` and `browserOnly: true`. No local service probe, MCP token, server-side files or local machine access is included. +Serve OUTPUT_DIRECTORY over HTTP, including from a subdirectory such as `/demo/`. HTML, player and build-template URLs resolve relative to the publication. The entry point is index.html. It loads project.forma.json and starts Editor with `initialProject` and `browserOnly: true`. No local service probe, MCP token, server-side files or local machine access is included. -A valid IndexedDB draft takes priority over the bundled starting project. Each visitor edits their own browser copy. If storage is unavailable or corrupt, startup falls back to the validated bundled project. The project menu exports .forma backups that include the GLB assets and scripts. Imported models are stored as embedded data in the browser draft. +A valid IndexedDB draft for this publication path and bundled project ID takes priority over its starting project. Different publications do not restore each other's drafts; the local editor retains its separate `active` draft. Legacy unscoped hosted drafts are not automatically migrated. Each visitor edits their own browser copy. If storage is unavailable or corrupt, startup falls back to the validated bundled project. The project menu exports .forma backups that include the GLB assets and scripts. Imported models are stored as embedded data in the browser draft. The build panel defaults to web export when disconnected. Native builds can be downloaded as build kits; direct native compilation and the local MCP service still require the local Forma installation. The exporter includes the player and build-kit resources needed for these downloads. diff --git a/docs/MCP_TOOLS.json b/docs/MCP_TOOLS.json index 9bdebc5..6f5bda5 100644 --- a/docs/MCP_TOOLS.json +++ b/docs/MCP_TOOLS.json @@ -701,7 +701,7 @@ }, { "name": "asset_import_glb", - "description": "Import GLB or embedded glTF using base64 bytes OR a path inside the project folder. External URLs are not fetched.", + "description": "Import GLB, glTF with companion files, or a ZIP containing one model and its textures. Use base64 bytes OR a path inside the project folder. External files resolve only within that folder; network URLs are not fetched.", "inputSchema": { "type": "object", "properties": { @@ -716,7 +716,7 @@ }, "name": { "type": "string", - "pattern": "\\.(glb|gltf)$" + "pattern": "\\.(glb|gltf|zip)$" }, "base64": { "type": "string", @@ -728,6 +728,11 @@ "instantiate": { "default": true, "type": "boolean" + }, + "asScene": { + "description": "Replace the project with the complete imported scene, using its active/first camera and lights. Undoable.", + "default": false, + "type": "boolean" } }, "required": [ diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index e79132d..8225b24 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -16,3 +16,15 @@ The local editor was opened in a desktop browser. Visual checks covered the init GitHub Actions repeats the clean install, build, type check and test suite on Node.js 22. The build runs before integration tests because they read the generated editor and player bundles. Native application binaries were not rebuilt for this engine-only package; the tests cover build packaging and orchestration, not real installation on every target device. + +## Blender import and regression validation — 2026-09-12 + +- Build and TypeScript checks pass after the Blender import changes. +- Added regression coverage for parent/child material isolation, nested hosted export URLs, draft namespaces, and shared first-person input handling. +- Generated glTF fixtures exercise skeletal animation, morph targets, material color/alpha/metallic/roughness, perspective and orthographic camera properties, light intensity, autoplay, independent instances and disposal. +- Import tests cover glTF resource ZIPs, missing files, traversal rejection, scene camera/lighting selection, and atomic MCP scene replacement with Undo. +- An optional real Blender 5.2.1 LTS integration test creates a synthetic scene, bakes a procedural material animated by a `frame` driver, converts a curve, exports shape keys/camera/light, checks scene isolation, and verifies unchanged input bytes. +- A local WebGL smoke test used a three-frame bake from an existing procedural material. All four atlas offsets changed together; pause held the frame fixed. The scene rendered in the browser. Personal project files and generated results remain outside Git. +- The exported browser-only editor loaded and ran a generated animated Blender scene under a nested URL. + +Full Cycles/EEVEE equivalence, every Blender node type, full user-scene conversion and device-specific performance are not claimed. See [Blender compatibility](BLENDER.md). diff --git a/editor/Editor.tsx b/editor/Editor.tsx index de8d262..9898bb1 100644 --- a/editor/Editor.tsx +++ b/editor/Editor.tsx @@ -70,8 +70,8 @@ import { } from "../engine/archive.ts"; import { FormaRuntime } from "../engine/runtime.ts"; import { saveDraft } from "./persistence.ts"; -import { loadEditorProject, type EditorStartupOptions } from "./startup.ts"; -import { inspectModel } from "../engine/model.ts"; +import { loadEditorProject, editorDraftKey, type EditorStartupOptions } from "./startup.ts"; +import { portableModel, modelComponents, modelDataUri, modelProject } from "../engine/model-import.ts"; import "./editor.css"; import { BuildPanel } from "./BuildPanel.tsx"; const names: Record = { @@ -278,7 +278,7 @@ function Modal({ ); } -export default function Editor({ initialProject, browserOnly = false }: EditorStartupOptions = {}) { +export default function Editor({ initialProject, browserOnly = false, draftScope }: EditorStartupOptions = {}) { const [project, setProject] = useState(null), [selected, setSelected] = useState(null), [connection, setConnection] = useState(null), @@ -328,6 +328,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt toastTimer = useRef(null), file = useRef(null), modelFile = useRef(null), + sceneFile = useRef(null), viewport = useRef(null), stickId = useRef(null), knob = useRef(null); @@ -406,7 +407,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt let alive = true; let unsubscribe: (() => void) | undefined; (async () => { - const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly }); + const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly, draftScope }); if (!alive) return; store.current = new ProjectStore(startingProject); setCached(restored); @@ -427,7 +428,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt clearTimeout(saveTimer.current); saveTimer.current = setTimeout( () => - saveDraft(store.current!.project) + saveDraft(store.current!.project, editorDraftKey({ initialProject, browserOnly, draftScope })) .then(() => setCached(true)) .catch((e) => addLog("error", String(e))), 500, @@ -492,7 +493,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt events.current?.close(); clearTimeout(saveTimer.current); }; - }, [accept, addLog, call, initialProject, browserOnly]); + }, [accept, addLog, call, initialProject, browserOnly, draftScope]); useEffect(() => { if (!ready || !canvas.current) return; const r = new FormaRuntime(canvas.current, { @@ -691,53 +692,51 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt setSelected(n.id); } }; - const importFiles = async (files: FileList | File[]) => { - for (const f of Array.from(files)) { + const importFiles = async (files: FileList | File[], asScene = false) => { + const selectedFiles = Array.from(files); + const entries = new Map(selectedFiles.map(file => [file.webkitRelativePath || file.name, file])); + for (const f of selectedFiles.filter(file => /\.(forma|json|zip|glb|gltf)$/i.test(file.name))) { setBusy("Импорт " + f.name); try { + if (f.size > 80 * 1024 * 1024) throw Error("Лимит файла импорта: 80 МБ"); + const bytes = new Uint8Array(await f.arrayBuffer()); if (/\.(forma|json|zip)$/i.test(f.name)) { - const p = unpackProject(new Uint8Array(await f.arrayBuffer())); - if (await cmd("project.replace", { project: p }, "Открыть проект")) { - setSelected(null); - notify("Проект открыт"); + let imported: Project | undefined; + try { imported = unpackProject(bytes); } + catch (error) { + if (!/\.zip$/i.test(f.name) || !(error instanceof Error) || error.message !== "Нет project.forma.json") throw error; } - } else if (/\.(glb|gltf)$/i.test(f.name)) { - if (f.size > 25 * 1024 * 1024) throw Error("Лимит модели: 25 МБ"); - const bytes = new Uint8Array(await f.arrayBuffer()); - const metadata = inspectModel(bytes, f.name); - const id = uid("asset"), - n = entity(f.name.replace(/\.(glb|gltf)$/i, ""), { - mesh: { type: "model", assetId: id }, - }); - if ( - await execute( - [ - { - op: "asset.upsert", - args: { - asset: { - id, - name: f.name, - kind: "model", - metadata, - uri: - "data:" + mimeFor(f.name) + ";base64," + base64(bytes), - }, - }, - }, - { op: "node.create", args: { entity: n } }, - ], - "Импорт " + f.name, - ) - ) - setSelected(n.id); - } else throw Error("Поддерживаются .forma, GLB, встроенный glTF"); - } catch (e) { - notify(String(e)); - addLog("error", String(e)); - } finally { - setBusy(""); - } + if (imported) { + if (await cmd("project.replace", { project: imported }, "Открыть проект")) { setSelected(null); notify("Проект открыт"); } + continue; + } + } + const model = await portableModel(bytes, f.webkitRelativePath || f.name, async path => { + const file = entries.get(path); + if (!file) throw Error("Отсутствует " + path + ". Упакуй glTF с ресурсами в ZIP."); + if (file.size > 25 * 1024 * 1024) throw Error("Лимит ресурса: 25 МБ"); + return new Uint8Array(await file.arrayBuffer()); + }); + if (asScene) { + if (await cmd("project.replace", { project: modelProject(model) }, "Открыть сцену Blender")) { + setSelected(null); notify("Сцена Blender открыта; исходную сцену можно вернуть через Undo"); + for (const warning of model.metadata.warnings) addLog("warning", warning); + } + continue; + } + const id = uid("asset"); + const n = entity(model.name.replace(/\.(glb|gltf)$/i, ""), modelComponents(id, model.metadata)); + const result = await execute([ + { op: "asset.upsert", args: { asset: { id, name: model.name, kind: "model", metadata: model.metadata, uri: modelDataUri(model) } } }, + { op: "node.create", args: { entity: n } }, + ], "Импорт " + model.name); + if (result) { + setSelected(n.id); + for (const warning of model.metadata.warnings) addLog("warning", warning, n.id); + notify(`Импортировано: ${model.metadata.nodes} узлов, ${model.metadata.clips.length} клипов, ${model.metadata.animatedProperties.length} анимированных свойств`); + } + } catch (error) { notify(String(error)); addLog("error", String(error)); } + finally { setBusy(""); } } }; const exportGame = async () => { @@ -1086,12 +1085,16 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt type="file" hidden multiple - accept=".glb,.gltf" + accept=".glb,.gltf,.zip,.bin,.png,.jpg,.jpeg,.webp" onChange={(e) => { if (e.target.files) void importFiles(e.target.files); e.target.value = ""; }} /> + { + if (e.target.files) void importFiles(e.target.files, true); + e.target.value = ""; + }} />
@@ -1359,18 +1362,6 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt ref={canvas} aria-label="3D-сцена" tabIndex={0} - onPointerDown={() => { - const r = runtime.current; - if ( - r?.playing && - !r.paused && - r.state.some((n) => n.components.camera?.mode === "firstPerson") - ) { - try { - void canvas.current?.requestPointerLock()?.catch(() => {}); - } catch {} - } - }} /> {!playing && ( <> @@ -1769,6 +1760,14 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt ) : type === "animator" ? ( <> +
+ + +
+ {["idle", "run", "attack", "death"].map((k) => (
@@ -1806,9 +1805,12 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt > + {node.components.mesh?.type === "model" && }
+ {c.mode === "imported" &&
} + {c.mode !== "imported" && <>