Fix editor regressions and add Blender scene and animated material import

This commit is contained in:
emil28092005
2026-09-12 00:53:20 +03:00
parent 1a0bf725fa
commit 6f497d1977
34 changed files with 1241 additions and 213 deletions
+2
View File
@@ -14,6 +14,8 @@
/native/output/
/coverage/
*.tsbuildinfo
__pycache__/
*.pyc
# Credentials and machine-local state
.env*
+2 -1
View File
@@ -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.
+476
View File
@@ -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('<I', data, 12)[0]
doc = json.loads(data[20:20+length])
binary = bytearray()
offset = 20 + length
while offset + 8 <= len(data):
size, kind = struct.unpack_from('<II', data, offset)
if kind == 0x004E4942:
binary.extend(data[offset+8:offset+8+size])
offset += 8 + size
return doc, binary
def write_glb(filename, doc, binary):
while len(binary) % 4:
binary.append(0)
doc['buffers'] = [{'byteLength': len(binary)}] if binary else []
data = json.dumps(doc, ensure_ascii=False, separators=(',', ':')).encode()
data += b' ' * (-len(data) % 4)
chunks = struct.pack('<II', len(data), 0x4E4F534A) + data
if binary:
chunks += struct.pack('<II', len(binary), 0x004E4942) + binary
Path(filename).write_bytes(struct.pack('<III', 0x46546C67, 2, 12 + len(chunks)) + chunks)
def accessor(doc, binary, values, components):
while len(binary) % 4:
binary.append(0)
offset = len(binary)
flat = [x for value in values for x in (value if isinstance(value, list) else [value])]
binary.extend(struct.pack('<' + 'f' * len(flat), *flat))
views = doc.setdefault('bufferViews', [])
views.append({'buffer': 0, 'byteOffset': offset, 'byteLength': len(flat) * 4})
accessors = doc.setdefault('accessors', [])
entry = {'bufferView': len(views)-1, 'componentType': 5126, 'count': len(values), 'type': 'SCALAR' if components == 1 else 'VEC' + str(components)}
if components == 1:
entry.update(min=[min(flat)], max=[max(flat)])
accessors.append(entry)
return len(accessors)-1
def attach_atlas_animations(doc, binary, bakes, scene):
fps = scene.render.fps / scene.render.fps_base
for bake in bakes:
index = next(i for i, m in enumerate(doc.get('materials', [])) if m.get('name') == bake['material'])
material = doc['materials'][index]
material['alphaMode'] = 'BLEND' if bake['alpha'] else 'OPAQUE'
paths = [('pbrMetallicRoughness/baseColorTexture', material.get('pbrMetallicRoughness', {}).get('baseColorTexture')), ('pbrMetallicRoughness/metallicRoughnessTexture', material.get('pbrMetallicRoughness', {}).get('metallicRoughnessTexture')), ('normalTexture', material.get('normalTexture')), ('emissiveTexture', material.get('emissiveTexture'))]
for property_path, info in paths:
if not info:
continue
info.setdefault('extensions', {})['KHR_texture_transform'] = {'scale': bake['scale'], 'offset': bake['offsets'][0]}
samplers = doc.setdefault('samplers', [])
samplers.append({'magFilter': 9729, 'minFilter': 9729, 'wrapS': 33071, 'wrapT': 33071})
doc['textures'][info['index']]['sampler'] = len(samplers)-1
used = doc.setdefault('extensionsUsed', [])
if 'KHR_texture_transform' not in used:
used.append('KHR_texture_transform')
if len(bake['frames']) <= 1:
continue
if 'KHR_animation_pointer' not in used:
used.append('KHR_animation_pointer')
times = [(frame - scene.frame_start) / fps for frame in bake['frames']]
times.append((scene.frame_end - scene.frame_start + 1) / fps)
offsets = bake['offsets'] + [bake['offsets'][0]]
animations = doc.setdefault('animations', [])
if not animations:
animations.append({'name': 'BlenderTimeline', 'channels': [], 'samplers': []})
animation = animations[0]
sampler = {'input': accessor(doc, binary, times, 1), 'output': accessor(doc, binary, offsets, 2), 'interpolation': 'STEP'}
animation['samplers'].append(sampler)
animation['channels'].append({'sampler': len(animation['samplers'])-1, 'target': {'path': 'pointer', 'extensions': {'KHR_animation_pointer': {'pointer': f'/materials/{index}/{property_path}/extensions/KHR_texture_transform/offset'}}}})
def main():
if not bpy.app.background:
raise RuntimeError('Use a separate Blender --background process to preserve the open authoring session')
options = arguments()
if options.resolution < 16 or options.resolution > 1024 or options.frame_step < 1 or not 1 <= options.samples <= 256:
raise RuntimeError('Resolution must be 161024, frame step >= 1, samples 1256')
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()
+1 -1
View File
@@ -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` (01) 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` (01) and `doubleSided`; scripts can update them through `api.patch`. Imported GLB materials retain their authoring settings unless a material override is requested.
## Controls
+72 -11
View File
@@ -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 161024 pixels per frame; `--samples` is 1256; 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).
+2 -2
View File
@@ -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.
+7 -2
View File
@@ -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": [
+12
View File
@@ -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).
+69 -63
View File
@@ -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<string, string> = {
@@ -278,7 +278,7 @@ function Modal({
</div>
);
}
export default function Editor({ initialProject, browserOnly = false }: EditorStartupOptions = {}) {
export default function Editor({ initialProject, browserOnly = false, draftScope }: EditorStartupOptions = {}) {
const [project, setProject] = useState<Project | null>(null),
[selected, setSelected] = useState<string | null>(null),
[connection, setConnection] = useState<any>(null),
@@ -328,6 +328,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
toastTimer = useRef<any>(null),
file = useRef<HTMLInputElement | null>(null),
modelFile = useRef<HTMLInputElement | null>(null),
sceneFile = useRef<HTMLInputElement | null>(null),
viewport = useRef<HTMLDivElement | null>(null),
stickId = useRef<number | null>(null),
knob = useRef<HTMLDivElement | null>(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 = "";
}}
/>
<input ref={sceneFile} type="file" hidden accept=".glb,.gltf,.zip" onChange={e => {
if (e.target.files) void importFiles(e.target.files, true);
e.target.value = "";
}} />
<header className="topbar">
<div className="brand">
<div className="brand-mark">
@@ -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" ? (
<>
<div className="property">
<label>Автозапуск при Play</label>
<select aria-label="Клип автозапуска" value={c.autoplay || ""} onChange={e => void componentField(type, "autoplay", e.target.value)}>
<option value="">Не запускать</option>
{(runtime.current?.importInfo.get(node.components.mesh?.assetId)?.clips || []).map((clip: string) => <option key={clip} value={clip}>{clip}</option>)}
</select>
</div>
<button className="wide-button" onClick={() => runtime.current?.previewAnimation(node.id, c.autoplay || runtime.current?.importInfo.get(node.components.mesh?.assetId)?.clips?.[0])}>Предпросмотр клипа</button>
{["idle", "run", "attack", "death"].map((k) => (
<div className="property" key={k}>
<label>{k}</label>
@@ -1806,9 +1805,12 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
>
<option value="follow">Следование</option>
<option value="fixed">Фиксированная</option>
{node.components.mesh?.type === "model" && <option value="imported">Из Blender / glTF</option>}
<option value="firstPerson">От первого лица</option>
</select>
</div>
{c.mode === "imported" && <div className="property"><label>Камера модели</label><select aria-label="Камера модели" value={c.cameraName || ""} onChange={e => void componentField(type, "cameraName", e.target.value)}><option value="">Первая камера</option>{(runtime.current?.importInfo.get(node.components.mesh?.assetId)?.cameraNames || []).map((name: string) => <option key={name} value={name}>{name}</option>)}</select></div>}
{c.mode !== "imported" && <>
<div className="property">
<label>Проекция</label>
<select
@@ -1873,6 +1875,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
onChange={(v) => void componentField(type, "fov", v)}
/>
</div>
</>}
</>
) : type === "character" ? (
<>
@@ -2082,9 +2085,12 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
))}
</div>
<div className="bottom-actions">
<button onClick={() => sceneFile.current?.click()} title="Открыть GLB или ZIP Blender как новый проект с камерами, светом и анимацией">
<Upload size={13} /> Открыть сцену Blender
</button>
<button onClick={() => modelFile.current?.click()}>
<Upload size={13} />
Импорт GLB
Импорт модели
</button>
<Icon
icon={HelpCircle}
+4 -4
View File
@@ -7,10 +7,10 @@ function db(): Promise<IDBDatabase> {
req.onerror = () => reject(req.error);
});
}
export async function loadDraft() {
export async function loadDraft(key = "active") {
const d = await db();
return new Promise<Project | null>((resolve, reject) => {
const r = d.transaction("projects").objectStore("projects").get("active");
const r = d.transaction("projects").objectStore("projects").get(key);
r.onsuccess = () => {
d.close();
if (r.result) {
@@ -25,11 +25,11 @@ export async function loadDraft() {
r.onerror = () => reject(r.error);
});
}
export async function saveDraft(p: Project) {
export async function saveDraft(p: Project, key = "active") {
const d = await db();
return new Promise<void>((resolve, reject) => {
const tx = d.transaction("projects", "readwrite");
tx.objectStore("projects").put(p, "active");
tx.objectStore("projects").put(p, key);
tx.oncomplete = () => {
d.close();
resolve();
+8 -3
View File
@@ -2,10 +2,15 @@ import { type Project, clone, validateProject } from '../engine/schema.ts';
import { defaultProject } from '../engine/templates.ts';
import { loadDraft } from './persistence.ts';
export interface EditorStartupOptions { initialProject?: Project; browserOnly?: boolean; }
export interface EditorStartupOptions { initialProject?: Project; browserOnly?: boolean; draftScope?: string; }
export function editorDraftKey(options: EditorStartupOptions, base = typeof document === "undefined" ? "http://localhost/" : document.baseURI) {
if (!options.browserOnly) return "active";
const scope = options.draftScope ?? new URL(".", base).pathname;
return `hosted:${scope}:${options.initialProject?.id ?? "blank"}`;
}
export async function loadEditorProject(
options: EditorStartupOptions = {},
dependencies: { request: typeof fetch; draft: () => Promise<Project | null> } = { request: fetch, draft: loadDraft },
dependencies: { request: typeof fetch; draft: (key: string) => Promise<Project | null> } = { request: fetch, draft: loadDraft },
) {
if (!options.browserOnly) {
try {
@@ -23,7 +28,7 @@ export async function loadEditorProject(
} catch {}
}
try {
const project = await dependencies.draft();
const project = await dependencies.draft(editorDraftKey(options));
if (project) {
validateProject(project);
return { project, status: null, restored: true };
+5 -2
View File
@@ -14,6 +14,9 @@ export function decodeData(uri: string) {
throw Error("Ожидался data URI base64");
return Uint8Array.from(atob(uri.slice(i + 1)), (c) => c.charCodeAt(0));
}
export function engineResource(path: string, base = typeof document === "undefined" ? undefined : document.baseURI) {
return base ? new URL(path.replace(/^\//, ""), base).href : path;
}
export async function readBytes(uri: string) {
if (uri.startsWith("data:")) return decodeData(uri);
const r = await fetch(uri);
@@ -57,8 +60,8 @@ export async function gameArchive(
) {
const { p, files } = await pack(project, read);
files["project.forma.json"] = strToU8(JSON.stringify(p));
files["player.js"] = await read("/engine/player.js");
files["player.css"] = await read("/engine/player.css");
files["player.js"] = await read(engineResource("/engine/player.js"));
files["player.css"] = await read(engineResource("/engine/player.css"));
files["index.html"] = strToU8(
'<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>' +
p.name.replace(/[<>&"]/g, "") +
+3 -3
View File
@@ -1,5 +1,5 @@
import { zipSync, unzipSync, strToU8 } from "fflate";
import { gameArchive, readBytes } from "./archive.ts";
import { gameArchive, readBytes, engineResource } from "./archive.ts";
import type { Project } from "./schema.ts";
import { normalizeOptions } from "../native/options.mjs";
export async function buildKit(
@@ -13,7 +13,7 @@ export async function buildKit(
for (const [name, data] of Object.entries(game))
files["native/game/" + name] = data;
const manifest = JSON.parse(
new TextDecoder().decode(await read("/build-targets/manifest.json")),
new TextDecoder().decode(await read(engineResource("/build-targets/manifest.json"))),
) as string[];
for (const name of manifest) {
if (
@@ -22,7 +22,7 @@ export async function buildKit(
name.startsWith("/")
)
throw Error("Invalid build template path");
files["native/" + name] = await read("/build-targets/" + name);
files["native/" + name] = await read(engineResource("/build-targets/" + name));
}
files["native/build-config.json"] = strToU8(JSON.stringify(options, null, 2));
files["README.txt"] = strToU8(
+40
View File
@@ -0,0 +1,40 @@
import "@babylonjs/loaders/glTF";
import { Animation } from "@babylonjs/core/Animations/animation.js";
import { AnimationPropertyInfo } from "@babylonjs/loaders/glTF/2.0/glTFLoaderAnimation.js";
import { SetInterpolationForKey } from "@babylonjs/loaders/glTF/2.0/Extensions/objectModelMapping.js";
// Babylon 9.25.0 registers metallicFactor twice and omits roughnessFactor.
// Its metallic-roughness texture pointer paths also omit KHR_texture_transform.
// Keep these corrections covered by actual glTF animation playback tests.
class MaterialProperty extends AnimationPropertyInfo {
buildAnimations(target: any, name: string, fps: number, keys: any[]) {
return Object.values(target._data || {}).map((data: any) => ({
babylonAnimatable: data.babylonMaterial,
babylonAnimation: this._buildAnimation(name, fps, keys),
}));
}
}
const scalar = (property: string, index = 0, stride = 1) => new MaterialProperty(
Animation.ANIMATIONTYPE_FLOAT, property,
(_target, source, offset, scale) => source[offset + index] * scale,
() => stride,
);
SetInterpolationForKey("/materials/{}/pbrMetallicRoughness/metallicFactor", [scalar("metallic")]);
SetInterpolationForKey("/materials/{}/pbrMetallicRoughness/roughnessFactor", [scalar("roughness")]);
const texturePath = "/materials/{}/pbrMetallicRoughness/metallicRoughnessTexture/extensions/KHR_texture_transform/";
SetInterpolationForKey(texturePath + "offset", [scalar("metallicTexture.uOffset", 0, 2), scalar("metallicTexture.vOffset", 1, 2)]);
SetInterpolationForKey(texturePath + "scale", [scalar("metallicTexture.uScale", 0, 2), scalar("metallicTexture.vScale", 1, 2)]);
SetInterpolationForKey(texturePath + "rotation", [new MaterialProperty(Animation.ANIMATIONTYPE_FLOAT, "metallicTexture.wAng", (_t, s, o, scale) => -s[o] * scale, () => 1)]);
// xmag/ymag are scalar half-extents; both sides use the same sample.
class CameraProperty extends AnimationPropertyInfo {
buildAnimations(target: any, name: string, fps: number, keys: any[]) {
return [{ babylonAnimatable: target._babylonCamera, babylonAnimation: this._buildAnimation(name, fps, keys) }];
}
}
for (const [axis, negative, positive] of [["xmag", "orthoLeft", "orthoRight"], ["ymag", "orthoBottom", "orthoTop"]]) {
SetInterpolationForKey(`/cameras/{}/orthographic/${axis}`, [
new CameraProperty(Animation.ANIMATIONTYPE_FLOAT, negative, (_t, s, o, scale) => -s[o] * scale, () => 1),
new CameraProperty(Animation.ANIMATIONTYPE_FLOAT, positive, (_t, s, o, scale) => s[o] * scale, () => 1),
]);
}
+89
View File
@@ -0,0 +1,89 @@
import { unzipSync } from "fflate";
import { base64, mimeFor } from "./archive.ts";
import { inspectModel, modelDocument } from "./model.ts";
import { emptyProject, entity, uid, activeScene } from "./schema.ts";
/** Resolve inside a supplied bundle. Never fetch network or arbitrary local files. */
export function modelResourcePath(model: string, uri: string) {
const decoded = decodeURIComponent(uri);
if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || /^[\\/]/.test(decoded) || /[\\?#\0]/.test(decoded))
throw Error("Недопустимый путь ресурса: " + uri);
const parts = model.split("/").slice(0, -1);
for (const part of decoded.split("/")) {
if (part === "..") { if (!parts.length) throw Error("Ресурс выходит за каталог импорта"); parts.pop(); }
else if (part && part !== ".") parts.push(part);
}
return parts.join("/");
}
export async function portableModel(
bytes: Uint8Array,
name: string,
read?: (path: string) => Promise<Uint8Array>,
): Promise<{ bytes: Uint8Array; name: string; metadata: ReturnType<typeof inspectModel> }> {
if (/\.zip$/i.test(name)) {
if (bytes.byteLength > 25 * 1024 * 1024) throw Error("Лимит ZIP модели: 25 МБ");
let size = 0;
const files = unzipSync(bytes, { filter: (file) => {
size += file.originalSize;
if (size > 80 * 1024 * 1024 || file.originalSize > 25 * 1024 * 1024)
throw Error("Слишком большой архив модели");
if (file.name.startsWith("/") || file.name.includes("\\") || file.name.split("/").includes(".."))
throw Error("Небезопасный путь в ZIP");
return true;
} });
const models = Object.keys(files).filter(path => /\.(glb|gltf)$/i.test(path) && !path.startsWith("__MACOSX/"));
if (models.length !== 1) throw Error("ZIP должен содержать ровно одну GLB/glTF-сцену и её ресурсы");
const model = models[0];
return portableModel(files[model], model, async path => {
if (!files[path]) throw Error("Отсутствует ресурс: " + path);
return files[path];
});
}
if (!/\.(glb|gltf)$/i.test(name)) throw Error("Ожидался GLB, glTF или ZIP модели");
if (/\.gltf$/i.test(name)) {
const doc = modelDocument(bytes, name);
let total = bytes.byteLength;
for (const [kind, list] of [["buffer", doc.buffers || []], ["image", doc.images || []]] as const) {
for (const item of list) {
if (!item.uri || item.uri.startsWith("data:")) continue;
const path = modelResourcePath(name, item.uri);
if (!read) throw Error("Добавь связанные файлы или импортируй ZIP: " + path);
const resource = await read(path);
total += resource.byteLength;
if (total > 25 * 1024 * 1024) throw Error("Лимит ресурсов модели: 25 МБ");
const ext = path.split(".").pop()?.toLowerCase();
const mime = kind === "buffer" ? "application/octet-stream" : ({ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" } as Record<string, string>)[ext || ""];
if (!mime) throw Error("Неподдерживаемая текстура: " + path);
item.uri = `data:${mime};base64,${base64(resource)}`;
}
}
bytes = new TextEncoder().encode(JSON.stringify(doc));
}
name = name.split("/").pop()!;
return { bytes, name, metadata: inspectModel(bytes, name) };
}
export function modelComponents(assetId: string, metadata: ReturnType<typeof inspectModel>) {
return {
mesh: { type: "model", assetId },
...(metadata.clips.length ? { animator: { autoplay: metadata.clips[0], loop: true, speed: 1 } } : {}),
};
}
export const modelDataUri = (model: { name: string; bytes: Uint8Array }) =>
`data:${mimeFor(model.name)};base64,${base64(model.bytes)}`;
/** Open a complete Blender scene as an undoable project replacement. */
export function modelProject(model: Awaited<ReturnType<typeof portableModel>>) {
const project = emptyProject(model.name.replace(/\.(glb|gltf)$/i, ""));
const id = uid("asset");
project.assets = [{ id, name: model.name, kind: "model", uri: modelDataUri(model), metadata: model.metadata }];
const components = { ...modelComponents(id, model.metadata), ...(model.metadata.cameras.length ? { camera: { mode: "imported", ...(model.metadata.activeCamera ? { cameraName: model.metadata.activeCamera } : {}) } } : {}) };
activeScene(project).entities = [entity(project.name, components)];
if (model.metadata.lights) {
project.settings.ambient = 0;
project.settings.rendering = { defaultLights: false };
}
return project;
}
+17 -2
View File
@@ -1,5 +1,5 @@
/** Validate portable model containers before allowing renderer-side resource loads. */
export function inspectModel(bytes: Uint8Array, name: string) {
export function modelDocument(bytes: Uint8Array, name: string) {
if (bytes.byteLength < 12 || bytes.byteLength > 25 * 1024 * 1024)
throw Error("Размер модели: 12 байт — 25 МБ");
let gltf: any;
@@ -24,6 +24,11 @@ export function inspectModel(bytes: Uint8Array, name: string) {
);
}
if (gltf.asset?.version !== "2.0") throw Error("Поддерживается glTF 2.0");
return gltf;
}
export function inspectModel(bytes: Uint8Array, name: string) {
const gltf = modelDocument(bytes, name);
if (gltf.asset?.version !== "2.0") throw Error("Поддерживается glTF 2.0");
if (
[...(gltf.buffers || []), ...(gltf.images || [])].some(
(r: any) => r.uri && !r.uri.startsWith("data:"),
@@ -41,12 +46,22 @@ export function inspectModel(bytes: Uint8Array, name: string) {
)
)
throw Error(
"Для автономного экспорта 0.1 используй GLB без Draco, Meshopt и KTX2",
"Используй экспорт без Draco, Meshopt и KTX2: автономные декодеры не включены",
);
return {
clips: (gltf.animations || []).map(
(a: any, i: number) => a.name || "Animation " + i,
),
skeletons: (gltf.skins || []).length,
nodes: (gltf.nodes || []).length,
meshes: (gltf.meshes || []).length,
materials: (gltf.materials || []).map((m: any, i: number) => m.name || `Material ${i}`),
cameras: (gltf.cameras || []).map((c: any, i: number) => c.name || `Camera ${i}`),
activeCamera: typeof gltf.extras?.forma?.activeCamera === "string" ? gltf.extras.forma.activeCamera : null,
lights: (gltf.extensions?.KHR_lights_punctual?.lights || []).length + (gltf.extensions?.EXT_lights_area?.lights || []).length,
morphTargets: (gltf.meshes || []).reduce((sum: number, m: any) => sum + Math.max(0, ...(m.primitives || []).map((p: any) => p.targets?.length || 0)), 0),
animatedProperties: [...new Set<string>((gltf.animations || []).flatMap((a: any) => (a.channels || []).map((c: any) => c.target?.extensions?.KHR_animation_pointer?.pointer).filter(Boolean)))],
extensions: gltf.extensionsUsed || [],
warnings: (Array.isArray(gltf.extras?.forma?.warnings) ? gltf.extras.forma.warnings : []).filter((w: any) => typeof w === "string").slice(0, 100),
};
}
-27
View File
@@ -74,7 +74,6 @@ async function boot() {
const stick = document.getElementById("stick")!;
const knob = stick.querySelector("i")!;
let stickId: number | null = null;
let look: { id: number; x: number; y: number } | null = null;
const move = (event: PointerEvent) => {
if (event.pointerId !== stickId || runtime.paused) return;
const bounds = stick.getBoundingClientRect();
@@ -111,37 +110,11 @@ async function boot() {
runtime.touch[input] = false;
};
}
canvas.addEventListener("pointerdown", (event) => {
if (!firstPerson || runtime.paused) return;
if (event.pointerType === "mouse") {
const lock = canvas.requestPointerLock?.();
if (lock && typeof lock.catch === "function") lock.catch(() => {});
} else {
look = { id: event.pointerId, x: event.clientX, y: event.clientY };
canvas.setPointerCapture(event.pointerId);
}
});
canvas.addEventListener("pointermove", (event) => {
if (!look || look.id !== event.pointerId || runtime.paused) return;
runtime.lookBy(
(event.clientX - look.x) * 0.004,
(look.y - event.clientY) * 0.004,
);
look.x = event.clientX;
look.y = event.clientY;
});
const release = () => {
stickId = null;
look = null;
runtime.releaseInput();
knob.style.transform = "";
};
canvas.addEventListener("pointerup", () => {
look = null;
});
canvas.addEventListener("pointercancel", () => {
look = null;
});
window.addEventListener("blur", release);
document.addEventListener("visibilitychange", () => {
release();
+67 -61
View File
@@ -1,5 +1,5 @@
import * as B from "@babylonjs/core";
import "@babylonjs/loaders/glTF";
import "./gltf-compat.ts";
import {
type Project,
type Entity,
@@ -13,6 +13,7 @@ import {
import { workerSource } from "./script-host.ts";
import { inspectModel } from "./model.ts";
import { CharacterMotor } from "./character.ts";
import { bindViewInput } from "./view-input.ts";
import { RuntimePresentation } from "./presentation.ts";
export interface RuntimeCallbacks {
select?: (id: string | null) => void;
@@ -108,6 +109,7 @@ export class FormaRuntime {
public callbacks: RuntimeCallbacks = {},
public options: RuntimeOptions = {},
) {
const runtime = this;
this.engine =
options.engine ||
new B.Engine(
@@ -121,6 +123,12 @@ export class FormaRuntime {
false,
);
if (!options.headless) {
this.cleanup.push(bindViewInput(canvas, {
get playing() { return runtime.playing; },
get paused() { return runtime.paused; },
firstPerson: () => this.firstPerson(),
lookBy: (x, y) => this.lookBy(x, y),
}));
const resize = new ResizeObserver(() => this.engine.resize());
resize.observe(canvas);
this.cleanup.push(() => resize.disconnect());
@@ -233,6 +241,7 @@ export class FormaRuntime {
this.actionQueue.add(name);
}
releaseInput() {
if (!this.options.headless && document.pointerLockElement === this.canvas) document.exitPointerLock();
this.keys.clear();
this.actionQueue.clear();
this.automation = null;
@@ -331,7 +340,7 @@ export class FormaRuntime {
}
scene.activeCamera = this.camera;
const sky = new B.HemisphericLight("Sky", B.Vector3.Up(), scene);
sky.intensity = p.settings.ambient;
sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
sky.groundColor = B.Color3.FromHexString("#8a8475");
const sun = new B.DirectionalLight(
"Sun",
@@ -339,7 +348,7 @@ export class FormaRuntime {
scene,
);
sun.position = new B.Vector3(-12, 22, -14);
sun.intensity = 2.5;
sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5;
sun.diffuse = B.Color3.FromHexString("#fff4df");
this.shadow = new B.ShadowGenerator(1024, sun);
this.shadow.usePercentageCloserFiltering = true;
@@ -451,11 +460,13 @@ export class FormaRuntime {
root.computeWorldMatrix(true);
const c = n.components.material;
if (c && (n.components.mesh?.type !== "model" || c.override)) {
for (const mesh of root.getChildMeshes()) {
for (const mesh of root.getChildMeshes().filter(mesh => mesh.metadata?.entityId === n.id)) {
const mat = mesh.material;
if (mat instanceof B.PBRMaterial) {
mat.albedoColor = B.Color3.FromHexString(c.color || "#91a697");
mat.emissiveColor = mat.albedoColor.scale(c.emissive || 0);
mat.roughness = c.roughness ?? 0.8;
mat.metallic = c.metallic ?? 0;
mat.alpha = c.alpha ?? 1;
mat.unlit = c.unlit === true;
mat.transparencyMode =
@@ -475,6 +486,8 @@ export class FormaRuntime {
node.dispose();
}
this.nodes.delete(id);
this.containers.get(id)?.dispose();
this.containers.delete(id);
this.signatures.delete(id);
this.animations.get(id)?.forEach((a) => a.dispose());
this.animations.delete(id);
@@ -490,7 +503,9 @@ export class FormaRuntime {
);
this.scene.shadowsEnabled = p.settings.shadows;
const sky = this.scene.getLightByName("Sky");
if (sky) sky.intensity = p.settings.ambient;
if (sky) sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
const sun = this.scene.getLightByName("Sun");
if (sun) sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5;
this.engine.setHardwareScalingLevel(1 / p.settings.renderScale);
const live = new Set(this.state.map((n) => n.id));
for (const id of this.nodes.keys()) if (!live.has(id)) this.removeNode(id);
@@ -532,61 +547,40 @@ export class FormaRuntime {
if (m.type === "model") {
const a = p.assets.find((a) => a.id === m.assetId);
if (!a?.uri) throw Error("У модели нет файла");
const key = a.id + "|" + a.uri;
let container = this.containers.get(key);
if (!container) {
let bytes: Uint8Array;
if (this.options.readAsset)
bytes = await this.options.readAsset(a.uri);
else {
const r = await fetch(a.uri);
if (!r.ok) throw Error("Ошибка загрузки " + a.name);
bytes = new Uint8Array(await r.arrayBuffer());
}
if (scene.isDisposed || this.disposed) return;
inspectModel(bytes, a.name);
container = await B.LoadAssetContainerAsync(bytes, scene, {
pluginExtension: a.name.toLowerCase().endsWith(".gltf")
? ".gltf"
: ".glb",
name: a.name,
});
if (scene.isDisposed || this.disposed) {
container.dispose();
return;
}
this.containers.set(key, container);
const info = {
clips: container.animationGroups.map((g) => g.name),
skeletons: container.skeletons.length,
triangles: container.meshes.reduce(
(s, m) => s + m.getTotalIndices() / 3,
0,
),
};
this.importInfo.set(a.id, info);
this.log(
"info",
"Импорт " +
a.name +
": " +
info.triangles +
" треугольников, " +
info.clips.length +
" анимаций",
);
let bytes: Uint8Array;
if (this.options.readAsset) bytes = await this.options.readAsset(a.uri);
else {
const response = await fetch(a.uri);
if (!response.ok) throw Error("Ошибка загрузки " + a.name);
bytes = new Uint8Array(await response.arrayBuffer());
}
const instance = container.instantiateModelsToScene(
(name) => n.id + "_" + name,
true,
{ doNotInstantiate: true },
);
for (const node of instance.rootNodes) node.parent = root;
this.animations.set(n.id, instance.animationGroups);
instance.animationGroups.forEach((g, i) => {
g.name = container!.animationGroups[i].name;
g.stop();
if (scene.isDisposed || this.disposed) return;
const metadata = inspectModel(bytes, a.name);
// A container per entity keeps cameras, lights, morph targets, textures
// and material animation targets independent. Mesh-only cloning can
// retain animation targets pointing into the cached source container.
const container = await B.LoadAssetContainerAsync(bytes, scene, {
pluginExtension: a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb",
name: a.name,
pluginOptions: { gltf: { animationStartMode: 0 } },
});
if (scene.isDisposed || this.disposed) { container.dispose(); return; }
this.containers.set(n.id, container);
// getNodes() also includes Bones, whose parent must remain a Bone.
const roots = [...container.meshes, ...container.transformNodes, ...container.cameras, ...container.lights].filter(node => !node.parent);
container.addAllToScene();
for (const imported of roots) imported.parent = root;
for (const camera of container.cameras) camera.detachControl();
this.animations.set(n.id, container.animationGroups);
container.animationGroups.forEach(group => group.stop());
this.importInfo.set(a.id, {
...metadata,
clips: container.animationGroups.map(group => group.name),
cameraNames: container.cameras.map(camera => camera.name),
triangles: container.meshes.reduce((sum, mesh) => sum + mesh.getTotalIndices() / 3, 0),
});
for (const warning of metadata.warnings) this.log("warning", warning, n.id);
this.log("info", `Импорт ${a.name}: ${metadata.nodes} узлов, ${metadata.clips.length} анимаций, ${metadata.animatedProperties.length} анимированных свойств`, n.id);
meshes = root.getChildMeshes();
} else if (m.type === "custom" || m.type === "geometry") {
const g =
@@ -712,7 +706,7 @@ export class FormaRuntime {
sign.isPickable = false;
}
for (const mesh of meshes) {
mesh.metadata = { entityId: n.id };
mesh.metadata = { ...mesh.metadata, entityId: n.id };
mesh.isPickable = true;
mesh.receiveShadows = n.components.mesh?.receiveShadows !== false;
if (n.components.mesh?.castShadows !== false)
@@ -829,6 +823,10 @@ export class FormaRuntime {
)?.components.camera;
this.look = { yaw: fp?.yaw ?? 0, pitch: fp?.pitch ?? 0 };
this.currentAnims.clear();
for (const node of this.state) {
const animator = node.components.animator;
if (node.enabled && animator?.autoplay) this.animate(node.id, animator.autoplay, animator.loop !== false);
}
if (!this.options.headless && p.settings.presentation) {
this.presentation?.dispose();
this.presentation = new RuntimePresentation(
@@ -1326,6 +1324,7 @@ export class FormaRuntime {
else this.flashes.set(id, t - dt);
}
try {
this.scene.animationsEnabled = !(this.playing && this.paused);
this.scene.render();
} catch (e) {
this.log("error", "Render: " + String(e));
@@ -1364,10 +1363,17 @@ export class FormaRuntime {
B.Vector3.FromArray(entity!.transform.position),
);
this.gameCamera.setTarget(B.Vector3.FromArray(c.lookAt || [0, 0, 0]));
this.gameCamera.fov = c.fov || 0.72;
}
private updateProjection() {
const c = this.state.find((n) => n.enabled && n.components.camera)
?.components.camera;
const cameraEntity = this.state.find(n => n.enabled && n.components.camera);
const c = cameraEntity?.components.camera;
if (cameraEntity && c?.mode === "imported") {
const cameras = this.containers.get(cameraEntity.id)?.cameras || [];
const imported = cameras.find(camera => camera.name === c.cameraName) || cameras[0];
if (imported) { this.scene.activeCamera = imported; return; }
}
this.scene.activeCamera = this.gameCamera;
const cam = this.gameCamera;
cam.mode =
c?.projection === "orthographic"
+1 -1
View File
@@ -58,7 +58,7 @@ export interface Project {
ambient: number;
shadows: boolean;
renderScale: number;
rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number };
rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number; defaultLights?: boolean };
controls?: Partial<
Record<"attack" | "jump" | "dash" | "sprint" | "reset", string[]>
>;
+38
View File
@@ -0,0 +1,38 @@
/** Shared by the editor and both standalone player presentations. */
export function bindViewInput(
canvas: HTMLCanvasElement,
runtime: { playing: boolean; paused: boolean; firstPerson: () => boolean; lookBy: (x: number, y: number) => void },
) {
let pointer: { id: number; x: number; y: number } | null = null;
const down = (event: PointerEvent) => {
if (!runtime.playing || runtime.paused || !runtime.firstPerson()) return;
if (event.pointerType === "mouse") {
if (event.button !== 0) return;
try { void canvas.requestPointerLock?.()?.catch(() => {}); } catch {}
} else {
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY };
canvas.setPointerCapture(event.pointerId);
}
};
const move = (event: PointerEvent) => {
if (!pointer || event.pointerId !== pointer.id) return;
if (!runtime.playing || runtime.paused || !runtime.firstPerson()) { pointer = null; return; }
runtime.lookBy((event.clientX - pointer.x) * .004, (pointer.y - event.clientY) * .004);
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY };
};
const release = () => { pointer = null; };
canvas.addEventListener("pointerdown", down);
canvas.addEventListener("pointermove", move);
canvas.addEventListener("pointerup", release);
canvas.addEventListener("pointercancel", release);
canvas.addEventListener("lostpointercapture", release);
canvas.addEventListener("blur", release);
return () => {
canvas.removeEventListener("pointerdown", down);
canvas.removeEventListener("pointermove", move);
canvas.removeEventListener("pointerup", release);
canvas.removeEventListener("pointercancel", release);
canvas.removeEventListener("lostpointercapture", release);
canvas.removeEventListener("blur", release);
};
}
+2
View File
@@ -25,6 +25,8 @@
"test": "node --import tsx --test tests/*.test.ts",
"typecheck": "tsc --noEmit",
"game:build": "node --import tsx scripts/build-game.ts",
"blender:export": "node scripts/export-blender.mjs",
"editor:export": "node --import tsx scripts/export-editor.ts",
"build:doctor": "node native/build.mjs --doctor",
"setup:desktop": "npm ci --prefix native",
"setup:android": "node native/setup-android.mjs"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { access } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const args = process.argv.slice(2);
if (!args.length || args.includes('--help')) {
console.log(`Usage: npm run blender:export -- INPUT.blend --output OUTPUT.glb [options]
--blender PATH Blender binary (or FORMA_BLENDER; default: blender)
--scene NAME Scene to export
--objects GLOB Export matching objects; repeat for several patterns
--bake-materials Bake procedural/animated surface inputs to UV atlases
--resolution 128 Pixels per baked frame, 161024
--samples 8 Cycles samples per bake, 1256
--lighting compat Unitless lights for Forma; spec preserves physical units
--start-frame N --end-frame N --frame-step N
--allow-lossy Accept approximations listed in OUTPUT.report.json
See docs/BLENDER.md for supported features and limitations.`);
process.exit(args.length ? 0 : 1);
}
const input = path.resolve(args.shift());
if (!/\.blend$/i.test(input)) throw Error('Input must be a .blend file');
await access(input);
const binaryFlag = args.indexOf('--blender');
let binary = process.env.FORMA_BLENDER || 'blender';
if (binaryFlag >= 0) {
if (!args[binaryFlag + 1]) throw Error('--blender requires a path');
binary = args[binaryFlag + 1]; args.splice(binaryFlag, 2);
}
const outputFlag = args.indexOf('--output');
if (outputFlag < 0 || !args[outputFlag + 1] || !/\.glb$/i.test(args[outputFlag + 1])) throw Error('--output OUTPUT.glb is required');
const output = path.resolve(args[outputFlag + 1]);
if (output === input) throw Error('Output must differ from input');
args[outputFlag + 1] = output;
const script = fileURLToPath(new URL('../blender/export_forma.py', import.meta.url));
const child = spawn(binary, ['--background', '--factory-startup', '--disable-autoexec', input, '--python-exit-code', '1', '--python', script, '--', ...args], { stdio: 'inherit', shell: false });
child.on('error', error => { console.error(`Cannot start Blender: ${error.message}. Set FORMA_BLENDER or --blender.`); process.exitCode = 1; });
child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); });
process.once('SIGINT', () => child.kill('SIGINT'));
process.once('SIGTERM', () => child.kill('SIGTERM'));
+1 -1
View File
@@ -22,5 +22,5 @@ for (const [sourceDir, targetDir] of [['hosted-editor', 'studio'], ['engine', 'e
}
await cp(path.join(engine, 'public/favicon.svg'), path.join(out, 'favicon.svg'));
const title = project.name.replace(/[<>&"']/g, '');
await writeFile(path.join(out, 'index.html'), `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Forma — ${title}</title><meta name="description" content="Редактор Forma с открытым проектом. Редактируй сцену, модели и скрипты; запускай игру и сохраняй проект."><link rel="icon" href="/favicon.svg"><link rel="stylesheet" href="/studio/editor.css"></head><body><div id="root"><p style="padding:32px;font:16px system-ui">Открываю проект Forma…</p></div><script type="module" src="/studio/editor.js"></script></body></html>`);
await writeFile(path.join(out, 'index.html'), `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Forma — ${title}</title><meta name="description" content="Редактор Forma с открытым проектом. Редактируй сцену, модели и скрипты; запускай игру и сохраняй проект."><link rel="icon" href="./favicon.svg"><link rel="stylesheet" href="./studio/editor.css"></head><body><div id="root"><p style="padding:32px;font:16px system-ui">Открываю проект Forma…</p></div><script type="module" src="./studio/editor.js"></script></body></html>`);
console.log(`Browser editor exported to ${out}; project: ${project.name}, revision ${project.revision}.`);
+8 -26
View File
@@ -11,7 +11,7 @@ import { defaultProject } from "../engine/templates.ts";
import { entity, uid, validateProject } from "../engine/schema.ts";
import { projectArchive, gameArchive, decodeData } from "../engine/archive.ts";
import { createMcp, type EngineService } from "./mcp.ts";
import { inspectModel } from "../engine/model.ts";
import { portableModel, modelComponents, modelDataUri, modelProject } from "../engine/model-import.ts";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
@@ -207,32 +207,14 @@ export async function createService(
const bytes = a.base64
? Buffer.from(a.base64, "base64")
: Buffer.from(await safeRead(projectDir, a.path));
const metadata = inspectModel(bytes, a.name),
gltf = a.name.toLowerCase().endsWith(".gltf");
const id = uid("asset"),
asset = {
id,
name: a.name,
kind: "model",
metadata,
uri:
"data:" +
(gltf ? "model/gltf+json" : "model/gltf-binary") +
";base64," +
bytes.toString("base64"),
},
commands: any[] = [{ op: "asset.upsert", args: { asset } }];
if (a.instantiate)
commands.push({
op: "node.create",
args: {
entity: entity(a.name.replace(/\.(glb|gltf)$/i, ""), {
mesh: { type: "model", assetId: id },
}),
},
});
const sourceName = a.path || a.name;
const model = await portableModel(bytes, sourceName, a.path ? file => safeRead(projectDir, file) : undefined);
const id = uid("asset");
const asset = { id, name: model.name, kind: "model", metadata: model.metadata, uri: modelDataUri(model) };
const commands: any[] = [{ op: "asset.upsert", args: { asset } }];
if (a.instantiate) commands.push({ op: "node.create", args: { entity: entity(model.name.replace(/\.(glb|gltf)$/i, ""), modelComponents(id, model.metadata)) } });
return store.transaction({
commands,
commands: a.asScene ? [{ op: "project.replace", args: { project: modelProject(model) } }] : commands,
expectedRevision: a.expectedRevision,
requestId: a.requestId,
label: "Импорт " + a.name,
+3 -2
View File
@@ -473,13 +473,14 @@ export function createMcp(s: EngineService) {
);
tool(
"asset_import_glb",
"Import GLB or embedded glTF using base64 bytes OR a path inside the project folder. External URLs are not fetched.",
"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.",
{
...revision,
name: z.string().regex(/\.(glb|gltf)$/i),
name: z.string().regex(/\.(glb|gltf|zip)$/i),
base64: z.string().max(36_000_000).optional(),
path: z.string().optional(),
instantiate: z.boolean().default(true),
asScene: z.boolean().default(false).describe("Replace the project with the complete imported scene, using its active/first camera and lights. Undoable."),
},
(a) => s.importModel(a),
);
+48
View File
@@ -0,0 +1,48 @@
"""Synthetic integration fixture; does not use any user's project assets."""
import bpy
import sys
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
scene = bpy.context.scene
scene.name = 'ExportFixture'
scene.frame_start, scene.frame_end, scene.render.fps = 1, 3, 24
bpy.ops.mesh.primitive_cube_add()
obj = bpy.context.object
obj.name = 'AnimatedCube'
material = bpy.data.materials.new('AnimatedMaterial')
material.use_nodes = True
obj.data.materials.append(material)
tree = material.node_tree
principled = tree.nodes.get('Principled BSDF')
noise = tree.nodes.new('ShaderNodeTexNoise')
noise.noise_dimensions = '4D'
noise.inputs['W'].driver_add('default_value').driver.expression = 'frame * 0.7'
tree.links.new(noise.outputs['Color'], principled.inputs['Base Color'])
for frame, value in [(1, .2), (3, .8)]:
principled.inputs['Roughness'].default_value = value
principled.inputs['Roughness'].keyframe_insert('default_value', frame=frame)
obj.shape_key_add(name='Basis')
key = obj.shape_key_add(name='Stretch')
key.data[0].co.z += 1
for frame, value in [(1, 0), (3, 1)]:
key.value = value
key.keyframe_insert('value', frame=frame)
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.name = 'ActiveCameraObject'
scene.camera.data.name = 'ActiveCameraData'
bpy.ops.object.light_add(type='POINT', location=(0, -2, 3))
bpy.context.object.data.energy = 100
curve = bpy.data.curves.new('CurveGeometry', 'CURVE')
curve.dimensions = '3D'
curve.bevel_depth = .1
spline = curve.splines.new('POLY')
spline.points.add(1)
spline.points[0].co, spline.points[1].co = (0, 0, 0, 1), (1, 1, 1, 1)
curve_obj = bpy.data.objects.new('ConvertedCurve', curve)
scene.collection.objects.link(curve_obj)
# A second scene must not leak into the chosen scene's export.
other = bpy.data.scenes.new('ExcludedScene')
other.collection.objects.link(bpy.data.objects.new('UnrelatedObject', bpy.data.meshes.new('Unused')))
scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(filepath=sys.argv[sys.argv.index('--') + 1])
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {promisify} from 'node:util';
import {execFile} from 'node:child_process';
import {mkdtemp,readFile,rm} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {inspectModel,modelDocument} from '../engine/model.ts';
const exec=promisify(execFile), binary=process.env.FORMA_BLENDER;
test('Blender exports shape keys, curves, cameras, lights and driver-baked animated textures', {skip:!binary,timeout:180000}, async()=>{
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-blender-'));
try{
const input=path.join(dir,'input.blend'),output=path.join(dir,'output.glb');
await exec(binary!,['--background','--factory-startup','--disable-autoexec','--python-exit-code','1','--python',path.resolve('tests/blender-fixture.py'),'--',input],{maxBuffer:4*1024*1024});
const original=await readFile(input);
await exec(process.execPath,['scripts/export-blender.mjs',input,'--output',output,'--bake-materials','--resolution','16','--samples','1'],{maxBuffer:4*1024*1024});
assert.deepEqual(await readFile(input),original,'the source .blend is never saved or modified');
const bytes=new Uint8Array(await readFile(output)),doc=modelDocument(bytes,'output.glb'),meta=inspectModel(bytes,'output.glb');
assert.equal(doc.scenes.length,1);assert.equal(meta.cameras.length,1);assert.equal(meta.lights,1);assert.equal(meta.morphTargets,1);
assert.equal(meta.activeCamera,'ActiveCameraData');
assert.ok(Math.abs(doc.extensions.KHR_lights_punctual.lights[0].intensity-100/(4*Math.PI))<.001,'Forma-compatible light units avoid overexposure');
assert.ok(doc.nodes.some((n:any)=>n.name==='ConvertedCurve'&&n.mesh!==undefined));
assert.ok(!doc.nodes.some((n:any)=>n.name==='UnrelatedObject'));
assert.ok(meta.animatedProperties.some(p=>p.endsWith('KHR_texture_transform/offset')));
const report=JSON.parse(await readFile(output.replace('.glb','.report.json'),'utf8'));
assert.equal(report.bakedMaterials[0].frames,3);
const material=doc.materials.find((m:any)=>m.name.startsWith('FormaBake_'));
assert.ok(material.pbrMetallicRoughness.baseColorTexture.extensions.KHR_texture_transform);
}finally{await rm(dir,{recursive:true,force:true});}
});
+36 -1
View File
@@ -39,7 +39,7 @@ export function findNode(p: any, id: string) {
}
/** A generated triangle, optionally skinned and animated; no game assets. */
export function triangleGlb(animated = false): Buffer {
export function triangleGlb(animated = false, configure?: (document: any, add: (data: Float32Array | Uint16Array, type: string, count: number, bounds?: Record<string, any>) => number) => void): Buffer {
const chunks: Buffer[] = [];
const views: any[] = [];
const accessors: any[] = [];
@@ -115,6 +115,7 @@ export function triangleGlb(animated = false): Buffer {
},
];
}
configure?.(document, add);
Object.assign(document, {
buffers: [{ byteLength: length }],
bufferViews: views,
@@ -153,3 +154,37 @@ export const triangleAsset = (animated = false) => ({
uri:
"data:model/gltf-binary;base64," + triangleGlb(animated).toString("base64"),
});
/** Material, camera, light and morph animation in one real glTF clip. */
export function sceneAnimationGlb() {
return triangleGlb(false, (doc, add) => {
doc.materials = [{ name: 'AnimatedPBR', pbrMetallicRoughness: { baseColorFactor: [1, 0, 0, 1], metallicFactor: .1, roughnessFactor: .2 } }];
doc.meshes[0].primitives[0].material = 0;
doc.meshes[0].primitives[0].targets = [{ POSITION: add(new Float32Array([0,0,0, 0,0,0, 0,1,0]), 'VEC3', 3) }];
doc.meshes[0].weights = [0];
doc.cameras = [{ name: 'ImportedCamera', type: 'perspective', perspective: { yfov: .7, znear: .1 } }];
doc.nodes.push({ name: 'ImportedCamera', camera: 0, translation: [0, 0, 5] }, { name: 'ImportedLight', extensions: { KHR_lights_punctual: { light: 0 } } });
doc.scenes[0].nodes.push(1, 2);
doc.cameras.push({ name: 'Ortho', type: 'orthographic', orthographic: { xmag: 2, ymag: 1, znear: .1, zfar: 100 } });
doc.nodes.push({ name: 'Ortho', camera: 1 });doc.scenes[0].nodes.push(3);
doc.extensions = { KHR_lights_punctual: { lights: [{ name: 'ImportedLight', type: 'point', intensity: 10 }] } };
doc.extensionsUsed = ['KHR_lights_punctual', 'KHR_animation_pointer'];
const input = add(new Float32Array([0, 1]), 'SCALAR', 2, { min: [0], max: [1] });
const animation: any = { name: 'Scene', channels: [], samplers: [] };
for (const [pointer, type, values] of [
['/materials/0/pbrMetallicRoughness/baseColorFactor', 'VEC4', [1,0,0,1, 0,0,1,.5]],
['/materials/0/pbrMetallicRoughness/metallicFactor', 'SCALAR', [.1,.9]],
['/materials/0/pbrMetallicRoughness/roughnessFactor', 'SCALAR', [.2,.8]],
['/cameras/0/perspective/yfov', 'SCALAR', [.7,1.1]],
['/cameras/1/orthographic/xmag', 'SCALAR', [2,4]],
['/cameras/1/orthographic/ymag', 'SCALAR', [1,3]],
['/extensions/KHR_lights_punctual/lights/0/intensity', 'SCALAR', [10,20]],
] as const) {
animation.channels.push({ sampler: animation.samplers.length, target: { path: 'pointer', extensions: { KHR_animation_pointer: { pointer } } } });
animation.samplers.push({ input, output: add(new Float32Array(values), type, 2), interpolation: 'LINEAR' });
}
animation.channels.push({ sampler: animation.samplers.length, target: { node: 0, path: 'weights' } });
animation.samplers.push({ input, output: add(new Float32Array([0,1]), 'SCALAR', 2), interpolation: 'LINEAR' });
doc.animations = [animation];
});
}
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {promisify} from 'node:util';
import {execFile} from 'node:child_process';
import {mkdtemp,writeFile,readFile,access,rm} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {gameArchive,engineResource} from '../engine/archive.ts';
import {buildKit} from '../engine/build-kit.ts';
import {defaultProject} from '../engine/templates.ts';
test('hosted editor HTML and downloadable builds resolve inside a nested publication',async()=>{
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-hosted-'));
const previous=Object.getOwnPropertyDescriptor(globalThis,'document');
try{
const project=defaultProject(true),source=path.join(dir,'source.forma.json'),out=path.join(dir,'demo');
await writeFile(source,JSON.stringify(project));
await promisify(execFile)(process.execPath,['--import','tsx','scripts/export-editor.ts',source,out]);
const html=await readFile(path.join(out,'index.html'),'utf8');
for(const match of html.matchAll(/(?:src|href)="([^"]+)"/g)){
assert.ok(match[1].startsWith('./'),match[1]);await access(path.join(out,match[1]));
}
Object.defineProperty(globalThis,'document',{value:{baseURI:'https://example.test/demo/index.html'},configurable:true});
const requested:string[]=[];
const read=async(uri:string)=>{requested.push(uri);assert.ok(uri.startsWith('https://example.test/demo/'),uri);return new Uint8Array(await readFile(path.join(out,new URL(uri).pathname.slice('/demo/'.length))));};
await gameArchive(project,read);await buildKit(project,{target:'linux',name:'Fixture'},read);
assert.ok(requested.includes('https://example.test/demo/engine/player.js'));
assert.ok(requested.includes('https://example.test/demo/build-targets/manifest.json'));
assert.equal(engineResource('/engine/player.js','https://example.test/demo/'),'https://example.test/demo/engine/player.js');
}finally{
if(previous)Object.defineProperty(globalThis,'document',previous);else delete (globalThis as any).document;
await rm(dir,{recursive:true,force:true});
}
});
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { zipSync, strToU8 } from 'fflate';
import { portableModel, modelComponents, modelResourcePath } from '../engine/model-import.ts';
import { inspectModel, modelDocument } from '../engine/model.ts';
import { sceneAnimationGlb } from './fixtures.ts';
test('model metadata exposes scene contents and material animation',()=>{
const m=inspectModel(sceneAnimationGlb(),'scene.glb');
assert.deepEqual(m.clips,['Scene']);assert.equal(m.lights,1);assert.equal(m.morphTargets,1);assert.equal(m.cameras.length,2);
assert.ok(m.animatedProperties.includes('/materials/0/pbrMetallicRoughness/roughnessFactor'));
assert.equal(modelComponents('a',m).animator!.autoplay,'Scene');
});
test('zipped glTF bundles embed relative buffers and textures for offline reload',async()=>{
const gltf={asset:{version:'2.0'},buffers:[{uri:'../geometry.bin',byteLength:4}],images:[{uri:'textures/color%20map.png'}]};
const zip=zipSync({'scene/model.gltf':strToU8(JSON.stringify(gltf)),'geometry.bin':new Uint8Array([1,2,3,4]),'scene/textures/color map.png':new Uint8Array([9,8,7])});
const model=await portableModel(zip,'scene.zip'),doc=modelDocument(model.bytes,model.name);
assert.equal(model.name,'model.gltf');assert.match(doc.buffers[0].uri,/^data:application\/octet-stream;base64,AQIDBA==$/);
assert.match(doc.images[0].uri,/^data:image\/png;base64,CQgH$/);
});
test('model import rejects missing resources, remote URLs, traversal and ambiguous bundles',async()=>{
for(const uri of ['../../outside.bin','https://example.test/a.bin','%2fetc/passwd','C:/secret','textures\\a.png']) assert.throws(()=>modelResourcePath('scene/a.gltf',uri));
await assert.rejects(()=>portableModel(strToU8(JSON.stringify({asset:{version:'2.0'},buffers:[{uri:'a.bin'}]})),'a.gltf'),/связанные/);
await assert.rejects(()=>portableModel(zipSync({'a.glb':sceneAnimationGlb(),'b.glb':sceneAnimationGlb()}),'model.zip'),/ровно одну/);
});
import {modelProject} from '../engine/model-import.ts';
import {validateProject,activeScene} from '../engine/schema.ts';
test('opening an imported scene uses its camera, timeline and lighting',async()=>{
const p=modelProject(await portableModel(sceneAnimationGlb(),'scene.glb'));
validateProject(p);const n=activeScene(p).entities[0];
assert.equal(n.components.camera.mode,'imported');assert.equal(n.components.animator.autoplay,'Scene');
assert.equal(p.settings.rendering!.defaultLights,false);assert.equal(p.assets.length,1);
});
+16
View File
@@ -184,3 +184,19 @@ test("real MCP HTTP + stdio share revisions, models, scripts, resources and expo
await rm(dir, { recursive: true, force: true });
}
});
test('MCP scene ZIP import replaces the scene atomically and Undo restores the prior project',async()=>{
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-scene-'));
const s=await createService({projectDir:dir,port:0,blank:true});const client=new Client({name:'scene-test',version:'1'});
try{
await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${s.port}/mcp`),{requestInit:{headers:{authorization:'Bearer '+s.token}}}));
const original=structuredClone(s.service.store.project);
const {zipSync}=await import('fflate');const {sceneAnimationGlb}=await import('./fixtures.ts');
const archive=zipSync({'scene.glb':sceneAnimationGlb()});
const result=await client.callTool({name:'asset_import_glb',arguments:{expectedRevision:0,name:'scene.zip',base64:Buffer.from(archive).toString('base64'),asScene:true}});
assert.ok(!result.isError,JSON.stringify(result.content));
const p=s.service.store.project,n=p.scenes[0].entities[0];assert.equal(p.revision,1);assert.equal(n.components.camera.mode,'imported');assert.equal(n.components.animator.autoplay,'Scene');
const undo=await client.callTool({name:'history_undo',arguments:{expectedRevision:1}});assert.ok(!undo.isError,JSON.stringify(undo.content));
assert.equal(s.service.store.project.id,original.id);assert.equal(s.service.store.project.assets.length,0);
}finally{await client.close();await s.close();await rm(dir,{recursive:true,force:true});}
});
+44
View File
@@ -312,3 +312,47 @@ test(
}
},
);
test('changing a parent material or transform does not repaint child entities', async () => {
const r = runtime(), p = defaultProject(true);
const parent = entity('Parent', {mesh:{type:'box'}, material:{color:'#ff0000',alpha:.5}}, [0,0,0], 'parent');
const child = entity('Child', {mesh:{type:'box'}, material:{color:'#00ff00',alpha:1}}, [2,0,0], 'child');
child.parentId = parent.id; activeScene(p).entities = [parent,child];
try {
await r.load(p);
parent.transform.position = [3,0,0]; parent.components.material.roughness = .1;
await r.load(p);
const mat:any = r.nodes.get('child')!.getChildMeshes()[0].material;
assert.deepEqual(mat.albedoColor.asArray(), [0,1,0]); assert.equal(mat.alpha,1);
assert.equal((r.nodes.get('parent')!.getChildMeshes().find(m=>m.metadata.entityId==='parent')!.material as any).roughness,.1);
} finally { r.dispose(); }
});
import { sceneAnimationGlb } from './fixtures.ts';
test('imported material, morph, camera and light animation targets are independent per entity', async () => {
const r=runtime(), p=defaultProject(true), bytes=sceneAnimationGlb();
p.assets=[{id:'scene',name:'scene.glb',kind:'model',uri:'data:model/gltf-binary;base64,'+bytes.toString('base64')}];
activeScene(p).entities=['a','b'].map(id=>entity(id,{mesh:{type:'model',assetId:'scene'},...(id==='a'?{camera:{mode:'imported'},animator:{autoplay:'Scene'}}:{})},[0,0,0],id));
try {
await r.play(p);
assert.ok(!r.logs.some(l=>l.level==='error'),JSON.stringify(r.logs));
const a=r.containers.get('a')!, b=r.containers.get('b')!;
assert.equal(r.scene.activeCamera,a.cameras[0]);
const group=r.animations.get('a')![0];
assert.ok(group.isStarted,'autoplay starts the imported timeline');
group.pause(); group.goToFrame(group.to);
const am:any=a.materials[0], bm:any=b.materials[0];
const close=(value:number,expected:number)=>assert.ok(Math.abs(value-expected)<.0001,`${value} != ${expected}`);
close(am.metallic,.9);close(am.roughness,.8);close(am.albedoColor.b,1);close(am.alpha,.5);
close(bm.metallic,.1);close(bm.roughness,.2);close(bm.albedoColor.r,1);
close(a.cameras[0].fov,1.1);close(b.cameras[0].fov,.7);
close(a.cameras[1].orthoLeft!,-4);close(a.cameras[1].orthoRight!,4);
close(a.cameras[1].orthoBottom!,-3);close(a.cameras[1].orthoTop!,3);
close(a.lights[0].intensity,20);close(b.lights[0].intensity,10);
close(a.morphTargetManagers[0].getTarget(0).influence,1);
close(b.morphTargetManagers[0].getTarget(0).influence,0);
activeScene(p).entities=activeScene(p).entities.filter(n=>n.id==='b');
await r.stop(p);await r.load(p);
assert.equal(r.containers.has('a'),false);assert.equal(r.containers.get('b')!.meshes[0].isDisposed(),false);
} finally {r.dispose();}
});
+13
View File
@@ -27,3 +27,16 @@ test('local editor still gives the connected Forma project priority over browser
const result=await loadEditorProject({}, {request:async(url)=>Response.json(url==='/api/status'?status:{project}),draft:async()=>{throw Error('Must not read draft');}});
assert.equal(result.project.name,project.name);assert.deepEqual(result.status,status);
});
import {editorDraftKey} from '../editor/startup.ts';
test('hosted drafts are isolated by publication path and bundled project ID',async()=>{
const a=defaultProject(true),b=defaultProject(true);a.id='a';b.id='b';
const opts={browserOnly:true,initialProject:a,draftScope:'/demo-a/'};
const key=editorDraftKey(opts);const saved=structuredClone(a);saved.name='Edited A';
const drafts=new Map([[key,saved]]);const draft=async(key:string)=>drafts.get(key)||null;
assert.equal((await loadEditorProject(opts,{draft,request:neverRequest})).project.name,'Edited A');
assert.equal((await loadEditorProject({...opts,initialProject:b},{draft,request:neverRequest})).project.name,b.name);
assert.equal((await loadEditorProject({...opts,draftScope:'/demo-b/'},{draft,request:neverRequest})).restored,false);
assert.notEqual(editorDraftKey({browserOnly:true,initialProject:a},'https://host.test/one/'),editorDraftKey({browserOnly:true,initialProject:a},'https://host.test/two/'));
assert.equal(editorDraftKey({}),'active');
});
+16
View File
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {bindViewInput} from '../engine/view-input.ts';
test('shared player/editor controls request mouse lock and route touch look only while active',()=>{
const canvas=new EventTarget() as HTMLCanvasElement;let locks=0;const moves:number[][]=[];
canvas.requestPointerLock=async()=>{locks++;};canvas.setPointerCapture=()=>{};
const runtime={playing:true,paused:false,firstPerson:()=>true,lookBy:(x:number,y:number)=>moves.push([x,y])};
const cleanup=bindViewInput(canvas,runtime);
const send=(type:string,props:any)=>canvas.dispatchEvent(Object.assign(new Event(type),props));
send('pointerdown',{pointerType:'mouse',button:0});assert.equal(locks,1);
send('pointerdown',{pointerType:'touch',pointerId:3,clientX:10,clientY:10});
send('pointermove',{pointerId:3,clientX:20,clientY:5});assert.deepEqual(moves,[[.04,.02]]);
runtime.paused=true;send('pointerdown',{pointerType:'mouse',button:0});send('pointermove',{pointerId:3,clientX:30,clientY:0});
assert.equal(locks,1);assert.equal(moves.length,1);
runtime.paused=false;cleanup();send('pointerdown',{pointerType:'mouse',button:0});assert.equal(locks,1);
});