Fix editor regressions and add Blender scene and animated material import
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
/native/output/
|
||||
/coverage/
|
||||
*.tsbuildinfo
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Credentials and machine-local state
|
||||
.env*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 16–1024, frame step >= 1, samples 1–256')
|
||||
scene = bpy.data.scenes.get(options.scene) if options.scene else bpy.context.scene
|
||||
if scene is None:
|
||||
raise RuntimeError('Scene not found')
|
||||
bpy.context.window.scene = scene
|
||||
scene.frame_start = options.start_frame if options.start_frame is not None else scene.frame_start
|
||||
scene.frame_end = options.end_frame if options.end_frame is not None else scene.frame_end
|
||||
if scene.frame_end < scene.frame_start:
|
||||
raise RuntimeError('End frame precedes start frame')
|
||||
frames = list(range(scene.frame_start, scene.frame_end + 1, options.frame_step))
|
||||
if options.bake_materials and len(frames) > 256:
|
||||
raise RuntimeError('At most 256 baked frames; increase --frame-step')
|
||||
scene.frame_set(scene.frame_start)
|
||||
objects = [o for o in scene.objects if not o.hide_render and (not options.objects or any(fnmatch.fnmatchcase(o.name, pattern) for pattern in options.objects))]
|
||||
if not objects:
|
||||
raise RuntimeError('No objects matched the export selection')
|
||||
warnings, unsupported, needs_bake = report_scene(objects, options.bake_materials)
|
||||
report = {'version': 1, 'blender': bpy.app.version_string, 'scene': scene.name, 'objects': len(objects), 'bakedMaterials': [], 'warnings': warnings + unsupported, 'frames': [scene.frame_start, scene.frame_end], 'frameStep': options.frame_step, 'lighting': options.lighting}
|
||||
output = Path(options.output).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path = output.with_suffix('.report.json')
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
if unsupported and not options.allow_lossy:
|
||||
raise RuntimeError('Unsupported features found; inspect ' + str(report_path) + '. Use --allow-lossy only to accept the listed approximations.')
|
||||
for obj in objects:
|
||||
if obj.type == 'LIGHT' and obj.data.type == 'AREA':
|
||||
obj.data = obj.data.copy()
|
||||
obj.data.type = 'POINT'
|
||||
deselect_all()
|
||||
for obj in objects:
|
||||
obj.hide_set(False)
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
object_names = [obj.name for obj in objects]
|
||||
curves = [obj for obj in objects if obj.type in {'CURVE', 'FONT', 'SURFACE'}]
|
||||
for obj in objects:
|
||||
obj.select_set(obj in curves)
|
||||
if curves:
|
||||
bpy.context.view_layer.objects.active = curves[0]
|
||||
bpy.ops.object.convert(target='MESH')
|
||||
# Conversion normally preserves object identity and names.
|
||||
objects = [bpy.data.objects.get(name) for name in object_names]
|
||||
objects = [obj for obj in objects if obj]
|
||||
for obj in objects:
|
||||
if obj.type != 'MESH' or obj.data.shape_keys:
|
||||
continue
|
||||
deselect_all()
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.data = obj.data.copy()
|
||||
for modifier in list(obj.modifiers):
|
||||
if modifier.type == 'ARMATURE':
|
||||
continue
|
||||
if not modifier.show_render:
|
||||
obj.modifiers.remove(modifier)
|
||||
continue
|
||||
modifier.show_viewport = True
|
||||
result = bpy.ops.object.modifier_apply(modifier=modifier.name)
|
||||
if 'FINISHED' not in result:
|
||||
raise RuntimeError(f'{obj.name}: could not apply modifier {modifier.name}')
|
||||
if options.bake_materials:
|
||||
modifier = obj.modifiers.new('Forma triangulation', 'TRIANGULATE')
|
||||
bpy.ops.object.modifier_apply(modifier=modifier.name)
|
||||
with tempfile.TemporaryDirectory(prefix='forma-bake-') as temporary:
|
||||
bakes = []
|
||||
if options.bake_materials:
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = options.samples
|
||||
for obj in objects:
|
||||
if obj.type != 'MESH' or not any(s.material and s.material.name in needs_bake for s in obj.material_slots):
|
||||
continue
|
||||
if any('Volume' in n.bl_idname for slot in obj.material_slots if slot.material for n in tree_nodes(slot.material.node_tree)):
|
||||
continue
|
||||
baked = bake_object(obj, scene, frames, options, temporary, warnings)
|
||||
if baked:
|
||||
bakes.append(baked)
|
||||
scene.frame_set(scene.frame_start)
|
||||
deselect_all()
|
||||
for obj in objects:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
properties = bpy.ops.export_scene.gltf.get_rna_type().properties
|
||||
required = ['export_pointer_animation', 'export_animation_mode']
|
||||
if any(name not in properties for name in required):
|
||||
raise RuntimeError('Install a Blender version with glTF Animation Pointer export support')
|
||||
settings = dict(filepath=str(output), export_format='GLB', use_selection=True, use_active_scene=True, export_animations=True, export_animation_mode='SCENE', export_pointer_animation=True, export_force_sampling=True, export_frame_range=True, export_frame_step=options.frame_step, export_skins=True, export_morph=True, export_morph_animation=True, export_cameras=True, export_lights=True, export_extras=True, export_texcoords=True, export_normals=True, export_tangents=True, export_anim_scene_split_object=False, export_convert_animation_pointer=True, export_bake_animation=True, export_gn_mesh=True, export_optimize_animation_size=False)
|
||||
settings['export_import_convert_lighting_mode'] = options.lighting.upper()
|
||||
bpy.ops.export_scene.gltf(**{key: value for key, value in settings.items() if key in properties})
|
||||
doc, binary = read_glb(output)
|
||||
attach_atlas_animations(doc, binary, bakes, scene)
|
||||
report['bakedMaterials'] = [{'material': b['material'], 'frames': len(b['frames'])} for b in bakes]
|
||||
report['warnings'] = sorted(set(report['warnings'] + warnings))
|
||||
active_camera = next((doc['cameras'][node['camera']].get('name') for node in doc.get('nodes', []) if scene.camera and node.get('name') == scene.camera.name and 'camera' in node), None)
|
||||
doc.setdefault('extras', {})['forma'] = {'version': 1, 'warnings': report['warnings'], 'source': 'Blender', 'activeCamera': active_camera, 'fps': scene.render.fps / scene.render.fps_base}
|
||||
write_glb(output, doc, binary)
|
||||
report['bytes'] = output.stat().st_size
|
||||
report['clips'] = [animation.get('name') for animation in doc.get('animations', [])]
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
print('FORMA_EXPORT ' + json.dumps(report, ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -6,7 +6,7 @@ Forma supports fixed orthographic scenes that combine 3D geometry and transparen
|
||||
|
||||
An entity can have `camera: { mode: "fixed", lookAt: [0, 0, 0], projection: "orthographic", orthoWidth: 24, aspect: 16 / 9 }`. The camera uses the entity's world position, faces `lookAt`, and preserves the specified aspect ratio with letterboxing. Omit `aspect` to fill the current viewport. The inspector exposes fixed mode, projection, width and look-at position. Perspective and follow/first-person cameras remain supported.
|
||||
|
||||
`settings.rendering` accepts `toneMapping`, `exposure` and `contrast`. Disabling tone mapping is useful for artwork that already contains lighting. Generated mesh materials accept `unlit`, `alpha` (0–1) and `doubleSided`; scripts can update them through `api.patch`. Imported GLB materials retain their authoring settings unless a material override is requested.
|
||||
`settings.rendering` accepts `toneMapping`, `exposure`, `contrast` and `defaultLights`. Set `defaultLights: false` to use only authored lights, for example in an imported Blender scene. Disabling tone mapping is useful for artwork that already contains lighting. Generated mesh materials accept `unlit`, `alpha` (0–1) and `doubleSided`; scripts can update them through `api.patch`. Imported GLB materials retain their authoring settings unless a material override is requested.
|
||||
|
||||
## Controls
|
||||
|
||||
|
||||
+72
-11
@@ -1,15 +1,76 @@
|
||||
# Blender and model import
|
||||
# Blender → Forma
|
||||
|
||||
1. Check the model's scale, armature, deformations and animation names.
|
||||
2. Export **glTF 2.0 → GLB** so geometry and textures travel in one file.
|
||||
3. Include skinning and the desired animation actions/NLA tracks in the export settings.
|
||||
4. Use **Импорт GLB** in Forma or drag the file into the editor. Import errors appear in the console.
|
||||
5. Preview imported animation clips from the animation component. Choose clip names in your project scripts.
|
||||
6. Add colliders and rigid-body or character components as needed. A model with feet at `y = 0` often needs an upward collider offset.
|
||||
7. Attach a behaviour to add movement or other interactions. A model alone does not provide game logic.
|
||||
Forma accepts **GLB**, **glTF with companion files**, and **ZIP containing one GLB/glTF scene and its resources**. Native `.blend` files are converted locally by Blender; the browser does not execute Blender or its node graphs.
|
||||
|
||||
Forma imports glTF PBR materials, model textures, skeletons and animation clips. Blender procedural materials and geometry-node workflows do not transfer directly; bake or apply them before export.
|
||||
Use **Импорт модели** to add an asset to the current scene. Select a glTF and its adjacent `.bin`/images together, or use ZIP to preserve subdirectories. **Открыть сцену Blender** opens a model as a new project, selects the converter's active camera (or the first imported camera) and autoplays its first timeline on Play. If lights are present, Forma's default sun and ambient light are disabled. Undo restores the preceding project. Cameras can be selected in the model entity's Camera component. Internal glTF nodes remain inside the model entity rather than becoming individually editable Forma entities.
|
||||
|
||||
Self-contained `.gltf` files with data URIs are supported. External `.bin` and texture file sets are not assembled by the editor; GLB is the simplest portable path. Draco, Meshopt and KTX2-compressed assets are rejected by the current importer to avoid external decoder requirements.
|
||||
## Direct export
|
||||
|
||||
Blender MCP is optional and is not installed by this repository. Forma's `arena` and `character` generators create editable hierarchies; `extrude`, `lathe` and `mesh_create` provide geometry tools without Blender. The compound character generator does not create a rig, and no neural text-to-3D model is bundled.
|
||||
Requires a Blender glTF exporter with `Animation Pointer` and `Scene` animation support. The integration test was run with **Blender 5.2.1 LTS**. Older versions without those options fail with a diagnostic.
|
||||
|
||||
```bash
|
||||
npm run blender:export -- /path/to/Scene.blend --output ./work/Scene.glb
|
||||
```
|
||||
|
||||
Set `FORMA_BLENDER` or pass `--blender /path/to/blender` if Blender is not on PATH. The command launches a separate background process, disables automatic Python execution and never saves the source `.blend` or touches the open Blender session.
|
||||
|
||||
The converter exports the selected Blender scene and its frame range, converts curves/text/surfaces into meshes, applies render-enabled mesh modifiers when there are no shape keys, retains armatures and shape keys, embeds textures, and exports cameras, punctual lights, extras and supported property animation. `--scene NAME` chooses a scene; repeated `--objects 'Name*'` options restrict the objects. Include the rig and other dependencies when exporting a subset.
|
||||
|
||||
The converter defaults to Blender's **Unitless** lighting mode (`--lighting compat`), matching Forma's ordinary exposure and avoiding overexposed physical lamps. `--lighting spec` preserves physical glTF light units and requires suitable runtime exposure. When using Blender's own exporter, choose Unitless lighting for the same result.
|
||||
|
||||
The exporter emits one sampled scene timeline. It is not an Action/NLA clip library exporter. Alternatively, use Blender's own GLB export to retain separately named Actions, enable Animation Pointer for supported material properties, then select the desired autoplay clip in Forma's Animator component.
|
||||
|
||||
## Animated procedural materials
|
||||
|
||||
```bash
|
||||
npm run blender:export -- /path/to/Scene.blend \
|
||||
--output ./work/Scene.glb --bake-materials \
|
||||
--resolution 128 --samples 8 \
|
||||
--start-frame 1 --end-frame 240 --frame-step 4
|
||||
```
|
||||
|
||||
The baker evaluates the material in Cycles at each sampled frame, including supported drivers, image sequences/movie frames and procedural inputs such as Noise, Voronoi, ColorRamp and Bump. It creates per-object UV atlases for base color/alpha, metallic/roughness, tangent-space normals and emission. It adds STEP animation of `KHR_texture_transform` offsets through `KHR_animation_pointer`. Forma plays these animations with the model timeline; pause also pauses materials.
|
||||
|
||||
Supported bake surfaces are Principled BSDF and Emission, optionally mixed with one Transparent shader. Existing UVs are used; meshes without UVs get a smart unwrap. Existing overlapping UV islands may produce artifacts and should be unwrapped before baking. Node groups feeding these inputs work through Cycles; arbitrary groups/mixes that produce a surface closure need conversion to a supported surface first. Unsupported closures fail explicitly.
|
||||
|
||||
`--resolution` is 16–1024 pixels per frame; `--samples` is 1–256; at most 256 sampled frames and an 8192px atlas edge are allowed. Two-pixel borders and linear filtering without mipmaps prevent adjacent frame bleeding. Smaller frame steps produce smoother animation and larger files. Static procedural materials use one atlas cell. Animated geometry can change a procedural material's appearance even without material drivers; add material animation or bake it explicitly as an animated source before relying on that case.
|
||||
|
||||
## Compatibility and limits
|
||||
|
||||
| Blender feature | Transfer |
|
||||
| --- | --- |
|
||||
| Mesh hierarchy, UVs, textures, transforms, extras | glTF model hierarchy; extras retained |
|
||||
| Armature, skinning, shape keys | Native glTF, including animation; no retargeting |
|
||||
| Curves, text, surfaces | Converted to mesh at export |
|
||||
| Compatible PBR, transparency, normal/emission textures | Native glTF materials |
|
||||
| Supported material, UV, camera and light keyframes | `KHR_animation_pointer`; independent targets per model instance |
|
||||
| Noise/Voronoi/ColorRamp/Bump and driver-driven surface inputs | Sampled texture baking for supported surface closures |
|
||||
| Movie/image sequence inputs | Sampled into atlases; no audio or live video playback |
|
||||
| Point, spot, sun lights; perspective/orthographic cameras | Native glTF; rendering differs from Cycles/EEVEE |
|
||||
| Area lights | Optional point-light approximation, listed in report |
|
||||
| Geometry Nodes and physics simulations | Evaluated start-frame geometry; no live simulation/cache playback |
|
||||
| Modifiers on meshes with shape keys | Preserved shape keys; unsupported modifier application is reported |
|
||||
| Volume shaders, World node graphs, compositor, render effects | No equivalent full transfer; use Forma fog/lighting or render baking |
|
||||
| View-dependent shading, true displacement, baked coat/transmission/thin-film etc. | Reported limitations; base PBR baking cannot reproduce them exactly |
|
||||
| Arbitrary OSL/Python nodes, custom Python drivers, Blender scripts | No runtime execution in Forma; complex disabled drivers need authoring-side preparation |
|
||||
|
||||
The converter writes `Scene.report.json` and embeds warnings in the GLB. Recognized unsupported features stop export by default. Inspect the report and use `--allow-lossy` to accept its listed approximations. This flag does not turn unsupported features into equivalents, and unsupported shader closures can still fail. Always inspect Blender's own exporter log too. A successful export is not a guarantee of pixel-identical Cycles/EEVEE rendering.
|
||||
|
||||
Current portable model limit: **25 MiB**, ZIP expansion budget: **80 MiB**. Split large scenes or reduce texture/frame resolution. External resources must be supplied locally inside the import bundle/project directory; network URLs and escaping paths are rejected. Draco, Meshopt and KTX2 are rejected because offline decoders are not bundled.
|
||||
|
||||
## MCP
|
||||
|
||||
`asset_import_glb` keeps its name for compatibility and accepts `.glb`, `.gltf`, or `.zip`. Supply `base64` or a path inside the Forma project directory. `instantiate: true` adds a model and Animator; `asScene: true` performs an undoable project replacement using imported cameras/lights. The browser editor has the same import pipeline.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm test
|
||||
FORMA_BLENDER=/path/to/blender node --import tsx --test tests/blender.test.ts
|
||||
```
|
||||
|
||||
The optional Blender integration test generates its own scene, exports driver-baked textures, a curve, shape keys, a camera and a light, and verifies that the source file is unchanged. Runtime tests exercise actual material/camera/light/morph animation and instance isolation. The shipped repository contains no personal Blender projects or baked game assets.
|
||||
|
||||
Reference: [Blender glTF exporter manual](https://docs.blender.org/manual/en/dev/addons/scene_gltf2.html).
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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[]>
|
||||
>;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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, 16–1024
|
||||
--samples 8 Cycles samples per bake, 1–256
|
||||
--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'));
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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])
|
||||
@@ -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
@@ -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];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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});}
|
||||
});
|
||||
|
||||
@@ -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();}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user