plugins_render3dEditor.js

/*
 * LittleJS 3D Editor Plugin
 * - A free camera to look around any 3D game as it runs, C on the debug overlay
 * - The 3D level editor, for a level loaded with level3DLoad, 0 on the debug overlay
 * - Debug builds only, like the 2D level editor: engineRelease.js has empty stubs for what other files call, so
 *   release builds carry none of it
 * - Goes after the Render3D plugins and the 2D level editor
 */

'use strict';

///////////////////////////////////////////////////////////////////////////////
// the view: a camera of the editor's own, drawn with while the free camera or the editor is on

const EDITOR3D_LOOK_SPEED = .003; // radians a pixel of mouse movement turns the view

// the camera of the free camera and the editor, made at the first use

/** @type {Camera3D|undefined} */
let editor3DCamera;
let editor3DFreeCamera = false; // the free camera is on
let editor3DFlySpeed = .2;      // world units flown in a frame of 1/60, the wheel changes it
let editor3DTimeLast = 0;       // real time of the last update, flying goes by real time so it works while paused
let editor3DWasLocked = false;  // the mouse was captured last update, a lock that is lost leaves the free camera
// the game's camera while the editor's is drawn with
/** @type {Camera3D|undefined} */
let editor3DGameCamera;

// if something draws with the editor's camera
function editor3DViewOn() { return editor3DFreeCamera || editor3DIsOpen; }

// the editor's camera starts as the game's: where it is, the way it looks and its field of view
function editor3DCameraStart()
{
    const from = render3D.camera, camera = editor3DCamera = new Camera3D;
    camera.pos = from.pos.copy();
    camera.rotation = vec3(from.rotation.x, from.rotation.y, 0);
    camera.fov = from.fov;
    camera.near = from.near;
    camera.far = from.far;
}

// turn the free camera on or off, it needs a Render3DPlugin and takes the keyboard and mouse while it is on
function editor3DSetFreeCamera(on)
{
    on = !!on && !!render3D;
    if (on === editor3DFreeCamera) return;
    editor3DFreeCamera = on;
    editor3DWasLocked = false;
    if (on)
    {
        editor3DCameraStart();
        pointerLockRequest();
    }
    else
        pointerLockIsActive() && pointerLockExit();
    inputCapture(editor3DViewOn());
}

// fly a camera: turn is the mouse movement in pixels, move is right, up and forward from -1 to 1
function editor3DFly(camera, move, turn, seconds, fast=false)
{
    const r = camera.rotation;
    camera.rotation = vec3(clamp(r.x - turn.y * EDITOR3D_LOOK_SPEED, -1.55, 1.55), r.y - turn.x * EDITOR3D_LOOK_SPEED, 0);
    const distance = editor3DFlySpeed * 60 * seconds * (fast ? 4 : 1);
    camera.pos = camera.pos.add(camera.getRight().scale(move.x * distance))
        .add(vec3(0, move.y * distance, 0))
        .add(camera.getForward().scale(move.z * distance));
}

// the keys that fly, right, up and forward from -1 to 1
function editor3DFlyKeys()
{
    const key = (code)=> keyIsDown(code) ? 1 : 0;
    return vec3(key('KeyD') - key('KeyA'), key('KeyE') - key('KeyQ'), key('KeyW') - key('KeyS'));
}

// run a function with render3D looking through the editor's camera, for the editor's picking and projecting
function editor3DWithView(fn)
{
    const r = render3D, camera = r.camera;
    r.camera = editor3DCamera;
    r.updateMatrices();
    try { return fn(); }
    finally
    {
        r.camera = camera;
        r.updateMatrices();
    }
}

// called by the 3D pass before it works out its matrices: the frame is drawn with the editor's camera
function editor3DCameraBegin()
{
    if (!editor3DViewOn() || !editor3DCamera) return;
    const r = render3D, camera = editor3DGameCamera = r.camera;
    r.camera = editor3DCamera;

    // where the game is looking from, a box turned its way and a line along its view; the editor has enough to show
    if (editor3DFreeCamera)
    {
        const color = hsl(.15, 1, .6);
        debugBox3D(camera.pos, vec3(.5, .5, .8), color, 0, camera.rotation);
        debugLine3D(camera.pos, camera.pos.add(camera.getForward().scale(2)), color);
    }

    // the editor's own drawing, in this frame's 3D pass
    editor3DIsOpen && render3DDebugPush(0, editor3DDraw);
}

// called by the 3D pass after the frame: the game's camera is back, with its matrices, for the game's next update
function editor3DCameraEnd()
{
    if (!editor3DGameCamera) return;
    render3D.camera = editor3DGameCamera;
    editor3DGameCamera = undefined;
    render3D.updateMatrices();
}

// if a captured mouse was let go of by the browser since the last look, as Chrome does on Escape, and not by the
// game with pointerLockExit
function editor3DLostLock(locked)
{
    const lost = editor3DWasLocked && !locked && !inputLockLetGo;
    editor3DWasLocked = locked;
    inputLockLetGo = false;
    return lost;
}

// C turns the free camera on while the debug keys work, the level editor has its own view
function editor3DFreeCameraKey()
{
    if (!(debugOverlay || debugKeysAlways) || levelEditor.isOpen || !keyWasPressed('KeyC')) return;
    inputClearKey('KeyC');
    editor3DSetFreeCamera(true);
}

// the free camera's update: C or Escape leaves, and so does a captured mouse the browser let go of, since Chrome
// takes Escape for that and may not send the key; the mouse looks while it is captured or the right button is held
function editor3DFreeCameraUpdate(seconds)
{
    const locked = pointerLockIsActive(), lostLock = editor3DLostLock(locked);
    if (keyWasPressed('KeyC') || debugKey && keyWasPressed(debugKey) || lostLock)
    {
        inputClearKey('KeyC');
        debugKey && inputClearKey(debugKey);
        editor3DSetFreeCamera(false);
        return;
    }
    editor3DFlySpeed = clamp(editor3DFlySpeed * Math.exp(-inputCaptureWheel * .2), .01, 10);
    const look = locked || mouseIsDown(2);
    editor3DFly(editor3DCamera, editor3DFlyKeys(), look ? inputCaptureDeltaScreen : vec2(), seconds,
        keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'));
}

///////////////////////////////////////////////////////////////////////////////
// the math of the handles: each is dragged with the mouse's ray

const EDITOR3D_UP = vec3(0, 1, 0);

// how far along an axis line, from its origin, the point nearest a ray is: the two lines' closest points, solved
// for the axis; a ray along the axis has no nearest point, and gives 0
function editor3DAxisDistance(ray, origin, axis)
{
    const d = ray.direction, w = origin.subtract(ray.origin);
    const a = axis.dot(axis), b = axis.dot(d), c = d.dot(d), e = axis.dot(w), f = d.dot(w);
    const denominator = a * c - b * b;
    return abs(denominator) < 1e-9 ? 0 : (b * f - c * e) / denominator;
}

// where a ray meets the plane through a point, undefined when it runs along the plane or away from it
function editor3DPlanePoint(ray, point, normal)
{
    const distance = raycastPlane(ray, point, normal);
    return distance === undefined ? undefined : ray.getPosition(distance);
}

// the axis a ring turns around and two axes across it, for the pitch 'x', yaw 'y' or roll 'z' of a rotation:
// yaw turns around world up, pitch around the side axis after the yaw, roll around the forward axis after both,
// so each ring changes one number of rotation3D; a turn goes from across toward along
function editor3DRingAxes(rotation, ring)
{
    const frame = buildMatrix(vec3(), ring === 'y' ? vec3() : ring === 'x' ? vec3(0, rotation.y, 0) :
        vec3(rotation.x, rotation.y, 0));
    const x = frame.transformDirection(vec3(1, 0, 0)), y = frame.transformDirection(vec3(0, 1, 0));
    const z = frame.transformDirection(vec3(0, 0, 1));
    return ring === 'x' ? {axis: x, across: y, along: z} : ring === 'y' ? {axis: y, across: z, along: x} :
        {axis: z, across: x, along: y};
}

// the angle of a ray's point on a ring's plane, around the ring's axis from across toward along
function editor3DRingAngle(ray, center, axes)
{
    const point = editor3DPlanePoint(ray, center, axes.axis);
    if (!point) return;
    const offset = point.subtract(center);
    return atan2(offset.dot(axes.along), offset.dot(axes.across));
}

// a value on the nearest step, a step of 0 leaves it
function editor3DSnap(value, step) { return step ? round(value / step) * step : value; }

// a position snapped to the grid by the low corner of its box, so its faces land on grid lines and boxes tile
// with no gaps; a turned object has no such corner and goes by its center
function editor3DSnapPos(pos, size, step, turned=false)
{
    const half = turned ? vec3() : size.scale(.5), low = pos.subtract(half);
    return vec3(editor3DSnap(low.x, step), editor3DSnap(low.y, step), editor3DSnap(low.z, step)).add(half);
}

// the height of the editor's view in world units at a point's depth, handles are sized by it to keep their size
// on screen
function editor3DScreenScale(point)
{
    const camera = editor3DCamera, depth = point.subtract(camera.pos).dot(camera.getForward());
    return max(depth, .01) * tan(camera.fov / 2) * 2;
}

// how far in pixels a screen position is from the line between two world points as the view shows it, Infinity
// when an end is behind the camera
function editor3DSegmentDistance(screenPos, a, b)
{
    const sa = render3D.worldToScreen(a), sb = render3D.worldToScreen(b);
    if (!sa || !sb) return Infinity;
    const ab = sb.subtract(sa), t = clamp(screenPos.subtract(sa).dot(ab) / (ab.lengthSquared() || 1), 0, 1);
    return screenPos.distance(sa.add(ab.scale(t)));
}

// the first point a ray hits: on an object, a height map or a voxel map, leaving some out, or on the ground plane
// at height 0 when it hits none
function editor3DSurface(ray, ignore=new Set)
{
    let distance;
    for (const o of engineObjects)
    {
        if (ignore.has(o)) continue;
        const d = render3DRaycastObject(ray, o);
        if (d !== undefined && (distance === undefined || d < distance))
            distance = d;
    }
    distance ??= raycastPlane(ray, vec3(), EDITOR3D_UP);
    return distance === undefined ? undefined : ray.getPosition(distance);
}

// where a box of a size stands with its bottom on a point
function editor3DRest(point, size) { return vec3(point.x, point.y + size.y / 2, point.z); }

// the height of the middle of a box that lands at a place across the ground: dropped from its own height over a
// height, onto what is under it, so it climbs a step as tall as itself and no more; with nothing under it, it
// stands at that height
function editor3DLand(x, z, height, size, ignore)
{
    const landed = editor3DDrop(vec3(x, height + size.y * 1.5 + .01, z), size, ignore);
    return landed ? landed.y : height + size.y / 2;
}

// where a box lands dropped straight down from its bottom onto what is under it, undefined with nothing there
function editor3DDrop(pos, size, ignore=new Set)
{
    const bottom = vec3(pos.x, pos.y - size.y / 2, pos.z);
    const point = editor3DSurface(new Ray3D(bottom, vec3(0, -1, 0)), ignore);
    return point && editor3DRest(point, size);
}

///////////////////////////////////////////////////////////////////////////////
// the level: the object the game gave level3DLoad is the source of truth, the editor changes it in place and
// brings what the game made in line with it

// the level being edited, the one given to level3DLoad last

/** @type {Object|undefined} */
let editor3DLevel;

// what the game made for each object of the level, by the object's id
const editor3DInstances = new Map;

// what the editor keeps for each level: {fileName, key, original, hash, pending, fileHandle, undo, redo}, the file
// it came from, the file's objects and their hash for the autosave, an autosave waiting for a file that changed,
// and its undo and redo lists
const editor3DRecords = new WeakMap;

// if the editor is open, and if it was opened and not exited, while Escape switches between playing and editing
let editor3DIsOpen = false, editor3DSession = false;
let editor3DGamePaused = false;   // the game's pause from before the editor opened
let editor3DPlayFromMouse = false; // Escape and Play hand the game a position to play from

const editor3DSelection = new Set; // the ids of the selected objects
// the type a click places
/** @type {string|undefined} */
let editor3DBrush;
// the copied objects
/** @type {Array<Object>|undefined} */
let editor3DClipboard;
let editor3DUndoList = [], editor3DRedoList = []; // the level's, each entry its object list before and after an edit
// the edit being made, a drag is one
/** @type {{before: Array<Object>}|undefined} */
let editor3DStroke;

const editor3DCopy = (value)=> JSON.parse(JSON.stringify(value));
const editor3DSame = (a, b)=> JSON.stringify(a) === JSON.stringify(b);
const editor3DRound = (v)=> round(v * 1e4) / 1e4; // as the file keeps a number

// the level's objects, and the one of an id
const editor3DObjects = ()=> isArray(editor3DLevel?.objects) ? editor3DLevel.objects : [];
const editor3DObject = (id)=> editor3DObjects().find((o)=> o.id === id);
const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.has(o.id));

// an object's position, rotation in degrees and scale, the defaults where the level leaves them out
const editor3DPos = (object)=> level3DVector(object.pos, vec3());
const editor3DRotation = (object)=> level3DVector(object.rotation, vec3());
const editor3DScale = (object)=> level3DVector(object.scale, vec3(1));
const editor3DTurned = (object)=> editor3DRotation(object).lengthSquared() > 0;

// the size of an object's box: what the game made for it says, a marker is 1 unit
function editor3DSize(object)
{
    const made = editor3DInstances.get(object.id);
    if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
    const s = made.size3D, k = made.scale3D;
    return vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
}

// every object gets an id of its own, a whole number: one without, or with one already used, gets a new one
function editor3DFixIds(objects)
{
    const isObject = (o)=> !!o && typeof o === 'object', seen = new Set;
    let next = 1;
    for (const o of objects)
        isObject(o) && Number.isInteger(o.id) && (next = max(next, o.id + 1));
    for (const o of objects)
    {
        if (!isObject(o)) continue;
        if (!Number.isInteger(o.id) || seen.has(o.id))
            o.id = next++;
        seen.add(o.id);
    }
}

// a quick hash of a text, to know when the file changed under its autosave
function editor3DHash(text)
{
    let hash = 2166136261;
    for (let i = 0; i < text.length; ++i)
        hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
    return hash >>> 0;
}

// called by level3DLoad before it makes the objects: the editor takes the level, and the first time it sees it,
// puts in the edits it autosaved for the file, or keeps them waiting when the file changed since
function editor3DLevelLoaded(level)
{
    if (editor3DLevel !== level)
    {
        // another level, the selection and the edit being made were the last one's
        editor3DSelection.clear();
        editor3DStroke = undefined;
    }
    editor3DLevel = level;
    editor3DInstances.clear();
    if (isArray(level.objects))
    {
        // an entry that is not an object is dropped, in place, a game may keep the list
        for (let i = level.objects.length; i--;)
            level.objects[i] && typeof level.objects[i] === 'object' || level.objects.splice(i, 1);
        editor3DFixIds(level.objects);
    }
    const known = editor3DRecords.get(level);
    if (known)
    {
        // loaded again, as a restart does: its edits are in it, and its undo is its own
        editor3DUndoList = known.undo;
        editor3DRedoList = known.redo;
        return;
    }

    // a level from a file goes by the file, one made in code by its objects as they were loaded, so each has an
    // autosave of its own
    const url = editorFetchedURLs.get(level)?.split(/[?#]/)[0];
    const original = editor3DCopy(editor3DObjects()), hash = editor3DHash(JSON.stringify(original));
    const record = {fileName: url ? url.split('/').pop() : 'level3D.json', key: url ?? 'level #' + hash, original,
        hash, pending: undefined, fileHandle: undefined, undo: [], redo: []};
    editor3DRecords.set(level, record);
    editor3DUndoList = record.undo;
    editor3DRedoList = record.redo;
    // an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back
    const saved = editor3DSaves()[record.key];
    if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original))
        editor3DAutosave(level);
    else if ((saved?.hash === hash || saved?.savedHash === hash) && isArray(saved.objects))
        level.objects = editor3DCopy(saved.objects);
    else if (saved)
        record.pending = saved;
}

// called by level3DLoad for each object, with what its type made
function editor3DObjectMade(object, made)
{
    made && typeof made === 'object' && editor3DInstances.set(object.id, made);
}

// put what the game made for an object where the level has it, and still
function editor3DPlaceInstance(made, object)
{
    made.pos3D = editor3DPos(object);
    made.rotation3D = editor3DRotation(object).scale(PI / 180);
    made.scale3D = (level3DBaseScale.get(made) ?? vec3(1)).multiply(editor3DScale(object));
    made.velocity3D = vec3();
    made.angleVelocity3D = vec3();
}

// make an object's game object, again when it has one
function editor3DMakeInstance(object)
{
    editor3DInstances.get(object.id)?.destroy?.();
    const made = level3DMake(object);
    made ? editor3DInstances.set(object.id, made) : editor3DInstances.delete(object.id);
}

// set the level's objects to a list and bring the game's objects in line: one gone is destroyed, a new one made,
// one that only moved, turned or changed size is moved where it is, and one whose type or properties changed is
// made again, since its constructor takes them; a type made by an arrow function is called again
function editor3DSetObjects(list)
{
    const before = new Map(editor3DObjects().map((o)=> [o.id, o]));
    const objects = editor3DLevel.objects = editor3DCopy(list);
    const ids = new Set(objects.map((o)=> o.id));
    for (const [id, made] of editor3DInstances)
    {
        if (ids.has(id)) continue;
        made.destroy?.();
        editor3DInstances.delete(id);
    }
    for (const id of [...editor3DSelection])
        ids.has(id) || editor3DSelection.delete(id);
    for (const object of objects)
    {
        const old = before.get(object.id), made = editor3DInstances.get(object.id);
        if (!level3DTypes.has(object.type) || old && editor3DSame(old, object)) continue;
        const onlyMoved = old && old.type === object.type && editor3DSame(old.properties, object.properties) &&
            made instanceof EngineObject3D && !made.destroyed;
        onlyMoved ? editor3DPlaceInstance(made, object) : editor3DMakeInstance(object);
    }
}

// change the level's objects as part of the edit being made: change edits a copy of the list; the changes until
// editor3DStrokeEnd are one undo, so a drag is one undo; false when nothing changed
function editor3DChange(change)
{
    if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
    const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before);
    change(after);
    if (editor3DSame(before, after)) return false;
    editor3DSetObjects(after);
    editor3DStroke ||= {before};
    return true;
}

// end the edit being made: one undo, and the autosave
function editor3DStrokeEnd()
{
    const stroke = editor3DStroke;
    editor3DStroke = undefined;
    if (!stroke || editor3DSame(stroke.before, editor3DObjects())) return;
    editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects())});
    editor3DUndoList.length > 100 && editor3DUndoList.shift();
    editor3DRedoList.length = 0;
    editor3DAutosave();
}

// take the edit being made back, with nothing to undo
function editor3DStrokeCancel()
{
    const stroke = editor3DStroke;
    editor3DStroke = undefined;
    stroke && editor3DSetObjects(stroke.before);
}

// undo the last edit, or redo the last one undone; false with none
function editor3DUndo(redo=false)
{
    editor3DStrokeEnd();
    const entry = (redo ? editor3DRedoList : editor3DUndoList).pop();
    if (!entry) return false;
    (redo ? editor3DUndoList : editor3DRedoList).push(entry);
    editor3DSetObjects(redo ? entry.after : entry.before);
    editor3DAutosave();
    return true;
}

// write an object's position, rotation in degrees and scale as the file keeps them: rounded, and the rotation and
// scale left out when they are the default; one not given is left as it is
function editor3DSetTransform(object, pos, rotation, scale)
{
    const list = (v)=> [editor3DRound(v.x), editor3DRound(v.y), editor3DRound(v.z)];
    pos && (object.pos = list(pos));
    if (rotation)
        list(rotation).some((v)=> v) ? object.rotation = list(rotation) : delete object.rotation;
    if (scale)
        list(scale).some((v)=> v !== 1) ? object.scale = list(scale) : delete object.scale;
}

// set an object's property as the file keeps it, only where it differs from its type's default: a Color as a hex
// string, with its alpha when it has one, and a vector as an array
function editor3DSetProperty(object, name, value, defaultValue)
{
    const encode = (v)=> isColor(v) ? v.toString(v.a < 1) : isVector3(v) ? [v.x, v.y, v.z] :
        isVector2(v) ? [v.x, v.y] : v;
    const properties = {...object.properties};
    if (editor3DSame(encode(value), encode(defaultValue)))
        delete properties[name];
    else
        properties[name] = encode(value);
    Object.keys(properties).length ? object.properties = properties : delete object.properties;
}

// add an object of a type at a position, selected; its id, or undefined when the level can not be edited
function editor3DPlace(type, pos)
{
    let id;
    const placed = editor3DChange((list)=>
    {
        const object = {id: id = list.reduce((next, o)=> max(next, o.id + 1), 1), type};
        editor3DSetTransform(object, pos);
        list.push(object);
    });
    if (!placed) return;
    editor3DSelection.clear();
    editor3DSelection.add(id);
    return id;
}

// remove the selected objects, as one undo
function editor3DDelete()
{
    if (!editor3DSelection.size) return false;
    editor3DStrokeEnd();
    editor3DChange((list)=>
    {
        for (let i = list.length; i--;)
            editor3DSelection.has(list[i].id) && list.splice(i, 1);
    });
    editor3DStrokeEnd();
}

// copy the selected objects
function editor3DCopySelection()
{
    const selected = editor3DSelected();
    if (!selected.length) return false;
    editor3DClipboard = editor3DCopy(selected);
}

// copy the selected objects and remove them
function editor3DCut() { return editor3DCopySelection() === false ? false : editor3DDelete(); }

// add the copied objects, moved by an offset, with new ids and selected, as one undo
function editor3DPaste(offset=vec3())
{
    const copied = editor3DClipboard, ids = [];
    if (!copied?.length) return false;
    editor3DStrokeEnd();
    const pasted = editor3DChange((list)=>
    {
        let id = list.reduce((next, o)=> max(next, o.id + 1), 1);
        for (const from of copied)
        {
            const object = {...editor3DCopy(from), id: id++};
            editor3DSetTransform(object, editor3DPos(from).add(offset));
            list.push(object);
            ids.push(object.id);
        }
    });
    editor3DStrokeEnd();
    if (!pasted) return false;
    editor3DSelection.clear();
    ids.forEach((id)=> editor3DSelection.add(id));
}

// copy the selected objects where they are and select the copies, the clipboard stays as it was
function editor3DDuplicate()
{
    const clipboard = editor3DClipboard;
    const done = editor3DCopySelection() === false ? false : editor3DPaste();
    editor3DClipboard = clipboard;
    return done;
}

///////////////////////////////////////////////////////////////////////////////
// the session: opening, playing and closing

// if the level editor is the 3D one: as levelEditor.use3D says, or when a 3D level was loaded
function editor3DWanted() { return !!render3D && (levelEditor.use3D ?? !!editor3DLevel); }

// put every object back where the level has it, and make one the game destroyed again, so what is on screen is
// what Save writes; a type made by an arrow function made nothing to put back
function editor3DRestore()
{
    for (const object of editor3DObjects())
    {
        const type = level3DTypes.get(object.type), made = editor3DInstances.get(object.id);
        if (!type) continue;
        if (made instanceof EngineObject3D && !made.destroyed)
            editor3DPlaceInstance(made, object);
        else if (type.make.prototype && (!made || made.destroyed))
            editor3DMakeInstance(object);
    }
}

// open or close the editor, the session goes on: the game is paused under it and reads no input
function editor3DSetOpen(open)
{
    open = !!open;
    if (!debug || editor3DIsOpen === open) return;
    if (open)
    {
        editor3DSetFreeCamera(false);
        editor3DLevel || editor3DLevelLoaded({}); // a new level, with nothing loaded
        editor3DCamera || editor3DCameraStart();
        editor3DGamePaused = paused;
        setPaused(true);
        setDebugOverlay(false); // out of the way of the level
        editor3DIsOpen = true;
        editor3DRestore();
    }
    else
    {
        editor3DDrag = editor3DHover = undefined;
        editor3DMouseOnPanel = false; // the panel hides, with no mouseleave
        editor3DStrokeEnd();
        editor3DIsOpen = false;
        setPaused(editor3DGamePaused);
    }
    inputCapture(editor3DViewOn());
}

// open the editor, a new session starts at the game's camera and a return keeps the editor's
function editor3DOpen()
{
    editor3DSession || editor3DCameraStart();
    editor3DSession = true;
    editor3DSetOpen(true);
}

// close the editor and end its session, Escape opens the debug overlay again
function editor3DClose()
{
    editor3DSession = false;
    editor3DSetOpen(false);
}

// switch to playing, from a position when Play from mouse is on
function editor3DPlay(pos)
{
    editor3DSetOpen(false);
    editor3DPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
}

// rebuild the level through the game's hook and play it
function editor3DRestart()
{
    if (!levelEditor.onRestart) return;
    editor3DSetOpen(false);
    levelEditor.onRestart();
}

// the level editor's open, close and isOpen go to the 3D editor when the game has a 3D level
if (debug)
{
    const open2D = levelEditor.open.bind(levelEditor), close2D = levelEditor.close.bind(levelEditor);
    levelEditor.open = ()=> editor3DWanted() ? editor3DOpen() : open2D();
    levelEditor.close = ()=> editor3DIsOpen || editor3DSession ? editor3DClose() : close2D();
    Object.defineProperty(levelEditor, 'isOpen', {get: ()=> editor3DIsOpen || editorIsOpen});
}

///////////////////////////////////////////////////////////////////////////////
// files: the level as JSON, Save, and the autosave

// a value as JSON on one line, with a space after each comma and colon, as a person would write it
function editor3DJSON(value)
{
    if (isArray(value))
        return '[' + value.map(editor3DJSON).join(', ') + ']';
    if (value && typeof value === 'object')
        return '{' + Object.entries(value).filter(([, v])=> v !== undefined)
            .map(([key, v])=> JSON.stringify(key) + ': ' + editor3DJSON(v)).join(', ') + '}';
    return JSON.stringify(value) ?? 'null';
}

// a level as the file has it: its version, what else it holds, and its objects, one to a line
function editor3DLevelJSON(level=editor3DLevel)
{
    const {littlejs3D, objects, ...rest} = level;
    const lines = [`"littlejs3D": ${LEVEL3D_VERSION}`];
    for (const [key, value] of Object.entries(rest))
        lines.push(JSON.stringify(key) + ': ' + editor3DJSON(value));
    const list = (isArray(objects) ? objects : []).map((o)=> '    ' + editor3DJSON(o));
    lines.push('"objects": [' + (list.length ? '\n' + list.join(',\n') + '\n  ]' : ']'));
    return '{\n  ' + lines.join(',\n  ') + '\n}\n';
}

// every page keeps its own autosaves, one for each level by its key
function editor3DSaveName() { return 'LittleJS editor 3D ' + (globalThis.location?.pathname ?? ''); }
const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});

// if the last autosave did not fit in storage, the panel says so
let editor3DSaveFailed = false;

// remember the level's objects, or forget them when they are back to the file
function editor3DAutosave(level=editor3DLevel)
{
    const record = editor3DRecords.get(level);
    if (!record || record.pending) return; // edits waiting to be applied keep their autosave
    const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
    // the level as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it,
    // since the browser gives a picked file's name and not its folder, and a file of the same name may be a copy
    if (editor3DSame(objects, record.original) && !record.savedHash)
        delete saves[record.key];
    else
        saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects)};
    try
    {
        localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
        editor3DSaveFailed = false;
    }
    catch { editor3DSaveFailed = true; }
}

// put the autosaved edits of a file that changed into the level, as one undo
function editor3DApplyPending()
{
    const record = editor3DRecords.get(editor3DLevel), saved = record?.pending;
    if (!saved || !isArray(saved.objects)) return false;
    record.pending = undefined;
    editor3DFixIds(saved.objects);
    editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(saved.objects)); });
    editor3DStrokeEnd();
    editor3DAutosave();
}

// drop the autosaved edits of a file that changed
function editor3DDropPending()
{
    const record = editor3DRecords.get(editor3DLevel);
    if (!record?.pending) return false;
    record.pending = undefined;
    editor3DAutosave();
}

// Reset to file: put the level back as its file has it, or as Save last wrote it, as one undo; edits waiting for a
// file that changed are dropped, and a level already as its file has it is left alone
function editor3DRevert()
{
    const record = editor3DRecords.get(editor3DLevel);
    if (!record) return false;
    record.pending = undefined;
    if (editor3DSame(editor3DObjects(), record.original))
        return editor3DAutosave(), false;
    editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(record.original)); });
    editor3DStrokeEnd();
}

// save the level as JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
// written again on each Save after, or picked again with Save As; elsewhere as a download; a file of the level's
// own name that was written is the file from then on, for the autosave; resolves to how it saved, undefined when
// the picker was closed
async function editor3DSave(pickAgain=false)
{
    const level = editor3DLevel, record = editor3DRecords.get(level);
    if (!record) return;
    editor3DStrokeEnd();
    // what is written, kept as it is now: the level can change while the file is picked and written, and those
    // edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
    // open by then
    const text = editor3DLevelJSON(level), written = editor3DCopy(JSON.parse(text).objects);
    const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
    const fileKey = (globalThis.location?.pathname ?? '') + ' 3D ' + record.key;
    if (picker)
    {
        try
        {
            if (!pickAgain && !record.fileHandle)
            {
                const handle = await editorFileStore.get(fileKey), mode = {mode: 'readwrite'};
                if (handle && (await handle.queryPermission(mode) === 'granted' ||
                    await handle.requestPermission(mode) === 'granted'))
                    record.fileHandle = handle;
            }
            if (pickAgain || !record.fileHandle)
            {
                record.fileHandle = await picker.call(globalThis, {suggestedName: record.fileName,
                    types: [{description: 'LittleJS 3D level', accept: {'application/json': ['.json']}}]});
                editorFileStore.set(fileKey, record.fileHandle);
            }
            const writable = await record.fileHandle.createWritable();
            await writable.write(text);
            await writable.close();
            if (record.fileHandle.name === record.fileName)
            {
                record.original = written;
                record.savedHash = editor3DHash(JSON.stringify(written));
                editor3DAutosave(level);
            }
            return 'written';
        }
        catch (error)
        {
            if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
            record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
            editorFileStore.set(fileKey, undefined);
        }
    }
    saveText(text, record.fileName, 'application/json');
    return 'downloaded';
}

///////////////////////////////////////////////////////////////////////////////
// the tools: handles on the selection, dragged with the mouse

const EDITOR3D_HANDLE_SIZE = .15; // a handle's length as a part of the view's height
const EDITOR3D_HANDLE_REACH = 10; // how many pixels from a handle still grab it
const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};

let editor3DTool = 'move';     // 'select', 'move', 'rotate' or 'scale'
let editor3DGrid = true;       // moves, turns and sizes go in steps, Ctrl flips it for a drag
let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
let editor3DHelp = false;      // the keys are shown
let editor3DMouseOnPanel = false;
// the drag being made: {kind, ...}, a 'box' drag has from and to in screen pixels
/** @type {any} */
let editor3DDrag;
// the handle under the mouse
/** @type {any} */
let editor3DHover;
// what an Alt drag orbits around
/** @type {Vector3|undefined} */
let editor3DOrbitPivot;

// the middle of the selection, where the handles are
function editor3DSelectionCenter()
{
    const selected = editor3DSelected();
    if (selected.length)
        return selected.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / selected.length);
}

// the handles of the tool on the selection, each {kind, axis, center, length, direction or axes, points}: Move has
// an arrow along each world axis and a square between each pair, Rotate a ring for the pitch, yaw and roll of the
// first selected object, Scale a handle along each of its own axes and a box in the middle for all three
function editor3DHandles()
{
    const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
    if (!center || tool === 'select') return handles;
    const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
    const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
    for (const axis of 'xyz')
    {
        if (tool === 'rotate')
        {
            const axes = editor3DRingAxes(rotation, axis), points = [];
            for (let i = 0; i <= 48; ++i)
            {
                const a = i / 48 * PI * 2;
                points.push(center.add(axes.across.scale(cos(a) * length)).add(axes.along.scale(sin(a) * length)));
            }
            handles.push({kind: 'ring', axis, center, length, axes, points});
            continue;
        }

        // an arrow or scale handle starts a little out from the middle, which is left for the object's body
        const direction = tool === 'move' ? EDITOR3D_AXES[axis] : frame.transformDirection(EDITOR3D_AXES[axis]);
        handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction,
            points: [center.add(direction.scale(length * .2)), center.add(direction.scale(length))]});
        if (tool === 'move')
        {
            // the square between the other two axes moves along both
            const [a, b] = 'xyz'.replace(axis, '');
            handles.push({kind: 'plane', axis: a + b, center, length, direction,
                points: [center.add(EDITOR3D_AXES[a].add(EDITOR3D_AXES[b]).scale(length * .35))]});
        }
    }
    tool === 'scale' && handles.push({kind: 'scaleAll', axis: 'xyz', center, length, points: [center]});
    return handles;
}

// the handle nearest a screen position, within reach; a square or box is a point and a little easier to take
function editor3DHandleAt(screenPos)
{
    let nearest, reach = EDITOR3D_HANDLE_REACH;
    for (const handle of editor3DHandles())
    {
        // a ring seen edge on is a line on screen and the mouse's ray runs along its plane, it can not be dragged
        const {points, center} = handle, toward = center.subtract(editor3DCamera.pos).normalize();
        if (handle.kind === 'ring' && abs(handle.axes.axis.dot(toward)) < .15) continue;

        // an arrow or scale handle seen end on is a dot over the object, which is left for the object's body
        if (points.length === 2)
        {
            const a = render3D.worldToScreen(points[0]), b = render3D.worldToScreen(points[1]);
            if (a && b && a.distance(b) < 12) continue;
        }
        let distance = Infinity;
        if (points.length === 1)
        {
            const at = render3D.worldToScreen(points[0]);
            at && (distance = screenPos.distance(at) - 4);
        }
        else
            for (let i = 1; i < points.length; ++i)
                distance = min(distance, editor3DSegmentDistance(screenPos, points[i-1], points[i]));
        if (distance < reach)
            reach = distance, nearest = handle;
    }
    return nearest;
}

// the object of the level a ray hits first: what the game made for it or a part attached to that, or a unit box
// where it made nothing to hit, like a light or a player start; its id
function editor3DPickAt(ray)
{
    let nearest, id;
    for (const object of editor3DObjects())
    {
        const parts = editor3DVisible(object) ? editor3DParts(editor3DInstances.get(object.id)) : [];
        let distance = parts.length ? undefined : raycastBox(ray, editor3DPos(object), vec3(1));
        for (const part of parts)
        {
            const d = render3DRaycastObject(ray, part);
            if (d !== undefined && (distance === undefined || d < distance))
                distance = d;
        }
        if (distance !== undefined && (nearest === undefined || distance < nearest))
            nearest = distance, id = object.id;
    }
    return id;
}

// what the game made for an object and the parts attached to it, its children and theirs
function editor3DParts(made, parts=[])
{
    if (made instanceof EngineObject && !made.destroyed)
    {
        parts.push(made);
        for (const child of made.children)
            editor3DParts(child, parts);
    }
    return parts;
}

// what the game made for the selected objects, with their parts, left out when the selection looks for a surface
// to stand on
function editor3DSelectedInstances()
{
    return new Set(editor3DSelected().flatMap((o)=> editor3DParts(editor3DInstances.get(o.id))));
}

// where each selected object is, as a drag starts from
function editor3DDragStart()
{
    return new Map(editor3DSelected().map((o)=>
        [o.id, {pos: editor3DPos(o), rotation: editor3DRotation(o), scale: editor3DScale(o)}]));
}

// the left button went down: on a handle it drags it, with a brush it places one, on an object it selects it and
// may drag it, and on nothing it drags out a box to select with
function editor3DPress(mouse, ray, shift)
{
    const handle = editor3DHandleAt(mouse), from = mouse.copy();
    if (handle)
    {
        const {kind, center, direction, axes} = handle;
        const grab = kind === 'ring' ? editor3DRingAngle(ray, center, axes) :
            kind === 'plane' ? editor3DPlanePoint(ray, center, direction) :
            kind === 'scaleAll' ? 0 : editor3DAxisDistance(ray, center, direction);
        editor3DDrag = {kind, handle, from, grab, start: editor3DDragStart()};
        return;
    }
    if (editor3DBrush)
    {
        editor3DPlaceAt(editor3DBrush, ray);
        shift || (editor3DBrush = undefined);
        editor3DTool = 'move';
        return;
    }
    const id = editor3DPickAt(ray);
    if (id === undefined)
    {
        editor3DDrag = {kind: 'box', from, to: from.copy(), shift};
        return;
    }
    if (shift)
    {
        editor3DSelection.has(id) ? editor3DSelection.delete(id) : editor3DSelection.add(id);
        return;
    }
    if (!editor3DSelection.has(id))
    {
        editor3DSelection.clear();
        editor3DSelection.add(id);
    }

    // a drag of its body moves it level, at its height, so it stays under the mouse; seen from the side, where the
    // mouse's ray runs along that plane, it moves across the view instead, in the upright plane facing it
    const pos = editor3DPos(editor3DObject(id)), forward = editor3DCamera.getForward();
    const side = abs(ray.direction.y) < .2 && vec3(forward.x, 0, forward.z).normalize();
    const normal = side && side.lengthSquared() ? side : EDITOR3D_UP;
    editor3DDrag = {kind: 'body', id, from, moving: false, ignore: editor3DSelectedInstances(), normal,
        start: editor3DDragStart(), level: editor3DPlanePoint(ray, pos, normal) ?? pos};
}

// place an object of a type where a ray lands, standing on what is there, snapped, selected, as one undo
function editor3DPlaceAt(type, ray)
{
    const point = editor3DSurface(ray);
    if (!point) return;
    editor3DStrokeEnd();
    const id = editor3DPlace(type, point);
    if (id === undefined) return;
    const size = editor3DSize(editor3DObject(id)), ignore = new Set(editor3DParts(editor3DInstances.get(id)));
    const snapped = editor3DSnapPos(editor3DRest(point, size), size, editor3DGrid ? editor3DMoveStep : 0);
    const y = editor3DLand(snapped.x, snapped.z, point.y, size, ignore);
    editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), vec3(snapped.x, y, snapped.z)));
    editor3DStrokeEnd();
}

// the mouse moved with a drag held: the selection goes from where the drag started to where the mouse says
function editor3DDragTo(drag, mouse, ray, snap)
{
    if (drag.kind === 'box')
    {
        drag.to = mouse.copy();
        return;
    }
    if (drag.kind === 'body' && !(drag.moving ||= mouse.distance(drag.from) > 4)) return;
    const {kind, handle, start, grab} = drag, first = editor3DObject(drag.id ?? [...start.keys()][0]);
    const was = first && start.get(first.id);
    if (!was) return;
    const size = editor3DSize(first), turned = editor3DTurned(first), step = snap ? editor3DMoveStep : 0;
    const snapTo = (to)=> editor3DSnapPos(to, size, step, turned).subtract(was.pos);
    let move, turn, factor;
    if (kind === 'arrow')
    {
        const d = editor3DAxisDistance(ray, handle.center, handle.direction) - grab;
        move = handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
    }
    else if (kind === 'plane')
    {
        const point = editor3DPlanePoint(ray, handle.center, handle.direction);
        if (!point || !grab) return;
        move = vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(point.subtract(grab))));
    }
    else if (kind === 'body')
    {
        // level under the mouse, and with ground snap it lands on what is under it there, from where it started
        const point = editor3DPlanePoint(ray, drag.level, drag.normal);
        if (!point) return;
        const across = snapTo(was.pos.add(point.subtract(drag.level)));
        const y = editor3DGroundSnap ? editor3DLand(was.pos.x + across.x, was.pos.z + across.z,
            was.pos.y - size.y / 2, size, drag.ignore) : was.pos.y;
        move = vec3(across.x, y - was.pos.y, across.z);
    }
    else if (kind === 'ring')
    {
        const angle = editor3DRingAngle(ray, handle.center, handle.axes);
        if (angle === undefined || grab === undefined) return;
        turn = mod((angle - grab) * 180 / PI + 180, 360) - 180; // the short way around
    }
    else if (kind === 'scale')
    {
        if (abs(grab) < 1e-6) return;
        factor = vec3(1);
        factor[handle.axis] = max(editor3DAxisDistance(ray, handle.center, handle.direction) / grab, .01);
    }
    else // the box in the middle sizes all three, right and up is larger
        factor = vec3(Math.exp((mouse.x - drag.from.x - mouse.y + drag.from.y) / 200));

    editor3DChange((list)=>
    {
        for (const object of list)
        {
            const s = start.get(object.id);
            if (!s) continue;
            if (move)
                editor3DSetTransform(object, s.pos.add(move));
            if (turn !== undefined)
            {
                const rotation = s.rotation.copy();
                rotation[handle.axis] = editor3DSnap(rotation[handle.axis] + turn, snap ? editor3DRotateStep : 0);
                editor3DSetTransform(object, undefined, rotation);
            }
            if (factor)
            {
                const size = snap ? editor3DScaleStep : 0, least = size || .01;
                const scale = s.scale.multiply(factor);
                editor3DSetTransform(object, undefined, undefined, vec3(max(editor3DSnap(scale.x, size), least),
                    max(editor3DSnap(scale.y, size), least), max(editor3DSnap(scale.z, size), least)));
            }
        }
    });
}

// the left button went up: a box selects what is inside it, by where each object's middle is on screen, and a
// press that never moved clears the selection; any other drag is one undo
function editor3DDragEnd(drag, mouse)
{
    editor3DDrag = undefined;
    if (drag.kind !== 'box')
        return editor3DStrokeEnd();
    drag.shift || editor3DSelection.clear();
    const {from} = drag;
    if (mouse.distance(from) < 4) return;
    const low = vec2(min(from.x, mouse.x), min(from.y, mouse.y));
    const high = vec2(max(from.x, mouse.x), max(from.y, mouse.y));
    for (const object of editor3DObjects())
    {
        const at = render3D.worldToScreen(editor3DPos(object));
        at && at.x >= low.x && at.x <= high.x && at.y >= low.y && at.y <= high.y &&
            editor3DSelection.add(object.id);
    }
}

// take the drag being made back, with nothing to undo
function editor3DDragCancel()
{
    editor3DDrag = undefined;
    editor3DStrokeCancel();
}

///////////////////////////////////////////////////////////////////////////////
// the editor's camera, keys and update

// the surface under a screen position, under the mouse and under the middle of the view, seen through the
// editor's camera
function editor3DScreenPoint(screenPos)
{ return editor3DWithView(()=> editor3DSurface(render3D.screenToRay(screenPos))); }
function editor3DMousePoint() { return editor3DScreenPoint(mousePosScreen); }
function editor3DViewPoint() { return editor3DScreenPoint(mainCanvasSize.scale(.5)); }

// move the camera back along its view until the selection is in the middle and fits
function editor3DFrame()
{
    const center = editor3DSelectionCenter(), camera = editor3DCamera;
    if (!center) return false;
    const radius = editor3DSelected().reduce((r, o)=>
        max(r, editor3DPos(o).distance(center) + editor3DSize(o).length() / 2), 0);
    camera.pos = center.subtract(camera.getForward().scale(max(radius * 2.5, 3)));
    render3D.updateMatrices();
}

// drop the selection straight down onto what is under it, each object by itself, as one undo
function editor3DDropSelection()
{
    const ignore = editor3DSelectedInstances();
    if (!editor3DSelection.size) return false;
    editor3DStrokeEnd();
    editor3DChange((list)=>
    {
        for (const object of list)
        {
            const to = editor3DSelection.has(object.id) &&
                editor3DDrop(editor3DPos(object), editor3DSize(object), ignore);
            to && editor3DSetTransform(object, to);
        }
    });
    editor3DStrokeEnd();
}

// paste the copied objects with their middle under the mouse, the lowest standing on what is there, snapped by
// the first of them; where they were when the mouse is over nothing
function editor3DPasteAtMouse()
{
    const copied = editor3DClipboard, point = !editor3DMouseOnPanel && editor3DMousePoint();
    if (!copied?.length) return false;
    if (!point) return editor3DPaste();
    const center = copied.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / copied.length);
    const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y - editor3DSize(o).y / 2), Infinity);
    const first = copied[0], was = editor3DPos(first);
    const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), editor3DSize(first),
        editor3DGrid ? editor3DMoveStep : 0, editor3DTurned(first));
    const land = editor3DLand(snapped.x, snapped.z, point.y, editor3DSize(first)) - editor3DSize(first).y / 2;
    return editor3DPaste(vec3(snapped.x - was.x, land - bottom, snapped.z - was.z));
}

// the keys by their position on the keyboard, each called with whether Shift is held; the tool keys are the ones
// Unity, Unreal and Godot use
/** @type {Object<string, function(boolean=): any>} */
const editor3DKeys =
{
    KeyQ: ()=> { editor3DTool = 'select'; editor3DBrush = undefined; },
    KeyW: ()=> { editor3DTool = 'move'; },
    KeyE: ()=> { editor3DTool = 'rotate'; },
    KeyR: ()=> { editor3DTool = 'scale'; },
    KeyG: ()=> { editor3DGrid = !editor3DGrid; },
    KeyF: ()=> editor3DFrame(),
    End: ()=> editor3DDropSelection(),
    Delete: ()=> editor3DDelete(),
    Backspace: ()=> editor3DDelete(),
    Slash: ()=> { editor3DHelp = !editor3DHelp; },
};
/** @type {Object<string, function(boolean=): any>} */
const editor3DCtrlKeys =
{
    KeyZ: (shift)=> editor3DUndo(shift),
    KeyY: ()=> editor3DUndo(true),
    KeyC: ()=> editor3DCopySelection(),
    KeyX: ()=> editor3DCut(),
    KeyV: ()=> editor3DPasteAtMouse(),
    KeyD: ()=> editor3DDuplicate(),
};

// the camera: the right button looks and the keys fly while it is held, the middle button or Space and the left
// pans, Alt and the left orbits the selection, and the wheel zooms toward what is under the mouse
function editor3DCameraUpdate(seconds, shift, alt)
{
    const camera = editor3DCamera, delta = inputCaptureDeltaScreen, wheel = inputCaptureWheel;
    const ahead = ()=> camera.pos.add(camera.getForward().scale(10));
    if (mouseIsDown(2))
    {
        editor3DFlySpeed = clamp(editor3DFlySpeed * Math.exp(-wheel * .2), .01, 10);
        editor3DFly(camera, editor3DFlyKeys(), delta, seconds, shift);
    }
    else if (mouseIsDown(1) || keyIsDown('Space') && mouseIsDown(0))
    {
        // the scene follows the mouse, at the depth of what is in the middle of the view
        const perPixel = editor3DScreenScale(editor3DViewPoint() ?? ahead()) / mainCanvasSize.y;
        camera.pos = camera.pos.subtract(camera.getRight().scale(delta.x * perPixel))
            .add(camera.getUp().scale(delta.y * perPixel));
    }
    else if (alt && mouseIsDown(0))
    {
        if (mouseWasPressed(0) || !editor3DOrbitPivot)
            editor3DOrbitPivot = editor3DSelectionCenter() ?? editor3DViewPoint() ?? ahead();
        const pivot = editor3DOrbitPivot, distance = camera.pos.distance(pivot);
        editor3DFly(camera, vec3(), delta, 0);
        camera.pos = pivot.subtract(camera.getForward().scale(distance));
    }
    else if (wheel && !editor3DMouseOnPanel)
    {
        // toward what is under the mouse, a part of the way there for each notch, and never through it
        render3D.updateMatrices();
        const ray = render3D.screenToRay(mousePosScreen), target = editor3DSurface(ray);
        const distance = target ? camera.pos.distance(target) : 10;
        camera.pos = camera.pos.add(ray.direction.scale(min(max(distance * .15, .2) * -wheel, distance - .5)));
    }
    render3D.updateMatrices(); // what follows picks and projects through the camera where it is now
}

// the editor's update, called while it is open with the input readable and render3D looking through its camera
function editor3DEditorUpdate(seconds)
{
    const held = (...codes)=> codes.some((code)=> keyIsDown(code));
    const shift = held('ShiftLeft', 'ShiftRight'), alt = held('AltLeft', 'AltRight');
    const ctrl = held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight');
    const mouse = mousePosScreen;

    // Escape, the debug key, puts a drag back, or else switches to playing; taken, so the debug overlay stays shut
    if (debugKey && keyWasPressed(debugKey))
    {
        inputClearKey(debugKey);
        editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
        return;
    }

    // 0 exits the editor; cleared, so debugKeysAlways does not open it again in the same step
    if (keyWasPressed('Digit0'))
    {
        inputClearKey('Digit0');
        editor3DClose();
        return;
    }

    editor3DCameraUpdate(seconds, shift, alt);
    const looking = mouseIsDown(2), cameraDrag = looking || mouseIsDown(1) || keyIsDown('Space') || alt;

    // the keys, which wait for a drag to end, and fly while the right button is held
    if (!editor3DDrag && !looking)
        for (const [code, action] of Object.entries(ctrl ? editor3DCtrlKeys : editor3DKeys))
            keyWasPressed(code) && action(shift);

    // a right press during a drag puts it back
    if (editor3DDrag && mouseWasPressed(2))
        return editor3DDragCancel();

    const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
    editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
    if (idle && mouseWasPressed(0))
        editor3DPress(mouse, ray, shift);

    // a quick click, down and up before this step, is a press and its release in one
    const drag = editor3DDrag;
    if (drag)
        mouseIsDown(0) ? editor3DDragTo(drag, mouse, ray, editor3DGrid !== ctrl) : editor3DDragEnd(drag, mouse);
}

// Ctrl with a letter the editor uses is not the browser's while the editor is open, Ctrl+D would bookmark the page
if (debug && globalThis.document?.addEventListener)
    document.addEventListener('keydown', (e)=>
    {
        if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVD]$/.test(e.code) && !editorIsTextField(e.target))
            e.preventDefault();
    });

///////////////////////////////////////////////////////////////////////////////
// what the panel does to the level, and what it says

// the help, every control
const editor3DHelpLines =
[
    'Left: select · Shift+Left: add or take away · Left drag from empty space: box select',
    'Q select · W move · E rotate · R scale: drag a handle, or the object itself',
    'G: grid snap, Ctrl flips it for a drag · End: drop to the ground',
    'Right button: look, and WASD and QE fly while it is held, Shift faster',
    'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
    'Pick a type, then click to place it, Shift+click keeps placing',
    'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
    'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
    'Esc: play and edit · 0: exit the editor · ?: keys',
];

// the hint line, for what is held and what is selected
function editor3DHint()
{
    if (editor3DRecords.get(editor3DLevel)?.pending)
        return 'The level file changed: apply your edits or drop them';
    if (editor3DDrag)
        return editor3DDrag.kind === 'box' ? 'Let go to select what is inside' :
            'Esc or right click puts it back · Ctrl flips the snap';
    if (inputCaptureRead(()=> mouseIsDown(2)))
        return 'WASD and QE fly · Shift faster · the wheel sets the speed';
    if (editor3DBrush)
        return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
    if (editor3DSelection.size)
        return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
    return 'Click to select · pick a type to place · the right button looks · ? keys';
}

// if the properties box has an input for a default's type
const editor3DPropertyEditable = (value)=> ['number', 'boolean', 'string'].includes(typeof value) ||
    isColor(value) || isVector2(value) || isVector3(value);

// set the position, rotation in degrees or scale of the one selected object, from the panel, as one undo
function editor3DSetSelectedTransform(field, value)
{
    const selected = editor3DSelected(), id = selected[0]?.id;
    if (selected.length !== 1 || !isVector3(value)) return false;
    editor3DStrokeEnd();
    editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), field === 'pos' ? value : undefined,
        field === 'rotation' ? value : undefined, field === 'scale' ? value : undefined));
    editor3DStrokeEnd();
    return true;
}

// set a property of the one selected object, from the panel, as one undo; the value has the type of its default
function editor3DSetSelectedProperty(name, value)
{
    const selected = editor3DSelected(), id = selected[0]?.id;
    const defaults = selected[0] && level3DTypes.get(selected[0].type)?.defaults;
    const d = defaults && Object.hasOwn(defaults, name) ? defaults[name] : undefined;
    const sameType = isColor(d) ? isColor(value) : isVector3(d) ? isVector3(value) :
        isVector2(d) ? isVector2(value) : typeof value === typeof d;
    if (selected.length !== 1 || !editor3DPropertyEditable(d) || !sameType) return false;
    editor3DStrokeEnd();
    editor3DChange((list)=> editor3DSetProperty(list.find((o)=> o.id === id), name, value, d));
    editor3DStrokeEnd();
    return true;
}

///////////////////////////////////////////////////////////////////////////////
// what the editor draws

const EDITOR3D_AXIS_COLORS = {x: hsl(0, .9, .55), y: hsl(.33, .9, .45), z: hsl(.6, .9, .6)};
const EDITOR3D_SELECT_COLOR = hsl(.15, 1, .6), EDITOR3D_MARKER_COLOR = hsl(.55, 1, .7);
const EDITOR3D_SOLID_COLOR = hsl(.08, 1, .55);

// a width in world units that shows as a number of pixels at a point
function editor3DPixels(point, pixels) { return editor3DScreenScale(point) / mainCanvasSize.y * pixels; }

// the 12 edges of the unit box a matrix places
function editor3DDrawWire(matrix, color, pixels=2)
{
    const corner = (i)=> matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
    const width = editor3DPixels(matrix.getTranslation(), pixels);
    for (let i = 0; i < 8; ++i)
    for (const bit of [1, 2, 4])
        i & bit || render3D.drawLine(corner(i), corner(i | bit), width, color);
}

// a ring around a point across two axes
function editor3DDrawRing(center, across, along, radius, color, pixels=2)
{
    const points = [];
    for (let i = 0; i <= 32; ++i)
    {
        const a = i / 32 * PI * 2;
        points.push(center.add(across.scale(cos(a) * radius)).add(along.scale(sin(a) * radius)));
    }
    render3D.drawRibbon(points, editor3DPixels(center, pixels), undefined, color);
}

// what the game made for an object, when it has something to see and click
function editor3DVisible(object)
{
    const made = editor3DInstances.get(object.id);
    return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo) ? made : undefined;
}

// the box around an object as a matrix: around its mesh where it is, turned and sized as it is, a unit box for a
// marker
function editor3DBoxMatrix(object)
{
    const made = editor3DVisible(object), bounds = made?.mesh && (made.mesh.bounds || made.mesh.getBounds());
    if (!made)
        return buildMatrix(editor3DPos(object));
    const matrix = render3DObjectMatrix(made).copy();
    return bounds ? matrix.multiply(buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined,
        bounds.max.subtract(bounds.min))) : matrix.multiply(buildMatrix(vec3(), undefined, made.size3D));
}

// the ground grid at height 0 around where the camera is, a line each unit and a stronger one each 10, fading
// with distance; drawn with the depth test so the level hides it, and a hair over 0 so a floor there does not
function editor3DDrawGrid()
{
    const camera = editor3DCamera.pos, reach = 40, y = .01;
    const x0 = round(camera.x), z0 = round(camera.z);
    for (let i = -reach; i <= reach; ++i)
    for (const across of [false, true])
    {
        const at = (across ? z0 : x0) + i, strong = at % 10 === 0;
        if (!strong && abs(i) > reach / 2) continue;
        const fade = 1 - abs(i) / (strong ? reach : reach / 2);
        const a = across ? vec3(x0 - reach, y, at) : vec3(at, y, z0 - reach);
        const b = across ? vec3(x0 + reach, y, at) : vec3(at, y, z0 + reach);
        render3D.drawLine(a, b, strong ? .04 : .02, hsl(0, 0, 1, (strong ? .35 : .15) * fade));
    }
}

// the cone a move arrow ends in, standing on Y, made the first time one is drawn
let editor3DCone;
const editor3DConeMesh = ()=> editor3DCone ||= buildCone(1, 1, 12);

// the editor's drawing in the 3D pass: the grid, a marker for what has nothing to see, the collision shape of
// what is solid and selected, the selection, the brush where it would go, and the handles
function editor3DDraw()
{
    const r = render3D;
    render3DWithState({depthTest: true}, editor3DDrawGrid);
    for (const object of editor3DObjects())
    {
        const made = editor3DVisible(object), selected = editor3DSelection.has(object.id);
        made || editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_MARKER_COLOR, 1.5);
        selected && editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_SELECT_COLOR, 2.5);
        if (made?.collideSolidObjects && !made.parent && selected)
        {
            // what it collides as, which turns with it, where its size3D is not its mesh's
            const shape = render3DSolidShape(made), color = EDITOR3D_SOLID_COLOR;
            if (shape.size)
                editor3DDrawWire(buildMatrix(shape.pos, shape.axes && made.rotation3D, shape.size), color, 1.5);
            else
                for (const [across, along] of [['x', 'y'], ['y', 'z'], ['z', 'x']])
                    editor3DDrawRing(shape.pos, EDITOR3D_AXES[across], EDITOR3D_AXES[along], shape.radius, color, 1.5);
        }
    }

    // the brush, a box where a click would place it
    const point = editor3DBrush && !editor3DMouseOnPanel && !editor3DDrag && editor3DMousePoint();
    if (point)
    {
        const snapped = editor3DSnapPos(editor3DRest(point, vec3(1)), vec3(1), editor3DGrid ? editor3DMoveStep : 0);
        editor3DDrawWire(buildMatrix(vec3(snapped.x, point.y + .5, snapped.z)), hsl(0, 0, 1, .7));
    }

    // the handles, the one under the mouse or being dragged in the selection's color
    for (const handle of editor3DHandles())
    {
        const {kind, axis, center, length, points} = handle;
        const taken = (editor3DDrag?.handle ?? editor3DHover);
        const lit = taken && taken.kind === kind && taken.axis === axis;
        const color = lit ? EDITOR3D_SELECT_COLOR : EDITOR3D_AXIS_COLORS[axis[0]] ?? WHITE;
        const width = editor3DPixels(center, lit ? 4 : 3), tip = length * .1;
        if (kind === 'ring')
            r.drawRibbon(points, width, undefined, color);
        else if (kind === 'plane')
        {
            // a flat square across its two axes, in the color of the axis it does not move along
            const size = vec3(tip * 1.6).subtract(handle.direction.scale(tip * 1.5));
            r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS['xyz'.replace(axis[0], '')
                .replace(axis[1], '')].scale(1, .7));
        }
        else if (kind === 'scaleAll')
            r.drawBox(center, tip * 1.4, lit ? color : WHITE);
        else if (kind === 'arrow')
        {
            // a move arrow ends in a cone pointing along it, which says move this way, where scale has a box
            r.drawLine(points[0], points[1], width, color);
            const turn = axis === 'x' ? vec3(0, 0, -PI / 2) : axis === 'z' ? vec3(PI / 2, 0, 0) : undefined;
            const at = points[1].add(handle.direction.scale(tip));
            r.drawMesh(editor3DConeMesh(), buildMatrix(at, turn, vec3(tip * 1.4, tip * 2, tip * 1.4)), undefined, color);
        }
        else
        {
            r.drawLine(points[0], points[1], width, color);
            r.drawBox(points[1], tip, color, editor3DRotation(editor3DSelected()[0]).scale(PI / 180));
        }
    }
}

// the editor's drawing on the 2D layer: the name of each marker, and the box being dragged out to select with
function editor3DDrawLabels()
{
    for (const object of editor3DObjects())
    {
        if (editor3DVisible(object)) continue;
        const at = render3D.worldToScreen(editor3DPos(object));
        at && drawTextScreen(object.type + '', at, 14, WHITE, 3, BLACK);
    }
    const drag = editor3DDrag;
    if (drag?.kind === 'box')
    {
        const {from: a, to: b} = drag, corners = [a, vec2(b.x, a.y), b, vec2(a.x, b.y)];
        corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, glEnable, true));
    }
}

///////////////////////////////////////////////////////////////////////////////
// the panel, in the look of the 2D level editor's, made the first time the editor opens

let editor3DPanel, editor3DPanelParts;

function editor3DPanelInit()
{
    const panel = editor3DPanel = editorElement('div', document.body,
        'position:fixed;top:8px;left:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' +
        'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' +
        'border-radius:4px;z-index:9999;user-select:none');

    // a click or touch on the panel is not the level's, a mouse up still goes on so a drag can let go
    for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel'])
        panel.addEventListener(type, (e)=> e.stopPropagation());
    panel.addEventListener('mouseenter', ()=> editor3DMouseOnPanel = true);
    panel.addEventListener('mouseleave', ()=> editor3DMouseOnPanel = false);

    const box = 'margin:4px 0;padding:4px;background:#222;border-radius:3px';
    const row = (parent=panel)=> editorElement('div', parent, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap');
    const button = (parent, text, onclick, title='')=>
    {
        const b = editorElement('button', parent, 'flex:1;padding:3px;cursor:pointer', text);
        b.onclick = (e)=> { onclick(e); b.blur(); }; // the keys go back to the editor
        b.title = title;
        return b;
    };
    const check = (parent, text, onchange, title='')=>
    {
        const label = editorElement('label', parent, 'display:flex;gap:6px;align-items:center;margin:2px 0');
        const input = editorElement('input', label);
        input.type = 'checkbox';
        input.onchange = ()=> { onchange(input.checked); input.blur(); };
        editorElement('span', label, '', text);
        label.title = title;
        return input;
    };
    const choice = (parent, text, values, onchange)=>
    {
        const label = editorElement('label', parent, 'display:flex;gap:3px;align-items:center;flex:1');
        editorElement('span', label, '', text);
        const select = editorElement('select', label, 'flex:1;background:#333;color:#eee');
        for (const value of values)
            editorElement('option', select, '', value + '').value = value + '';
        select.onchange = ()=> { onchange(parseFloat(select.value)); select.blur(); };
        return select;
    };

    editorElement('div', panel, 'font-weight:bold', '3D Level Editor');
    const top = row();
    button(top, 'Play', ()=> editor3DPlay(editor3DViewPoint()), 'Esc, and Esc again comes back to the editor');
    const restart = button(top, 'Restart', editor3DRestart, 'Rebuild the level and play it');
    button(top, 'Exit', ()=> levelEditor.close(), '0, then Esc opens the debug overlay again');
    const undo = row();
    button(undo, 'Undo', ()=> editor3DUndo(), 'Ctrl+Z');
    button(undo, 'Redo', ()=> editor3DUndo(true), 'Ctrl+Y');
    button(undo, 'Keys', ()=> editor3DHelp = !editor3DHelp, 'Every control, ?');

    // a file that changed under its autosave
    const pending = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
    editorElement('div', pending, '', 'The level file changed since your autosaved edits');
    const pendingRow = editorElement('div', pending, 'display:flex;gap:4px;margin-top:4px');
    button(pendingRow, 'Apply edits', ()=> editor3DApplyPending());
    button(pendingRow, 'Drop them', ()=> editor3DDropPending());

    // the tools, and the snapping
    const tools = row(), toolButtons = {};
    for (const [tool, text, key] of [['select', 'Select', 'Q'], ['move', 'Move', 'W'], ['rotate', 'Rotate', 'E'],
        ['scale', 'Scale', 'R']])
        toolButtons[tool] = button(tools, text, ()=>
        {
            editor3DTool = tool;
            tool === 'select' && (editor3DBrush = undefined);
        }, key);
    const snap = editorElement('div', panel, box);
    const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
    const steps = row(snap);
    const moveStep = choice(steps, 'Move', [1, .5, .25], (v)=> editor3DMoveStep = v);
    const rotateStep = choice(steps, 'Turn', [15, 45, 90], (v)=> editor3DRotateStep = v);
    const scaleStep = choice(steps, 'Size', [1, .5, .25], (v)=> editor3DScaleStep = v);
    const ground = check(snap, 'Ground snap', (on)=> editor3DGroundSnap = on,
        'A drag of an object slides it along what is under the mouse');

    // the types, a click picks one up to place, made again when types are added
    editorElement('div', panel, 'color:#aaa;margin-top:4px', 'Place');
    const types = row();
    const properties = editorElement('div', panel, box);

    const file = row();
    // Save says so for a moment when it saved, Save As shows where a page can write files
    const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3));
    const save = button(file, 'Save', ()=> editor3DSave().then(saved(save, 'Save')),
        'Save, to the file picked the first time, or a download');
    const saveAs = button(file, 'Save As', ()=> editor3DSave(true).then(saved(saveAs, 'Save As')),
        'Save As, to a file picked again');
    button(file, 'Reset to file', ()=> editor3DRevert(),
        'Put the level back as its file has it, or as Save last wrote it; Undo brings the edits back');
    /** @type {any} */ (globalThis).showSaveFilePicker || (saveAs.style.display = 'none');
    const playFrom = check(panel, 'Play from mouse', (on)=> editor3DPlayFromMouse = on,
        'Escape starts play with the player at the mouse, Play at the middle of the view');

    const storage = editorElement('div', panel, 'color:#f86;margin-top:4px',
        'Autosave failed, storage is full: Save to a file');
    const hint = editorElement('div', panel, 'color:#8ab;margin-top:4px');
    const help = editorElement('div', panel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
    for (const line of editor3DHelpLines)
        editorElement('div', help, 'margin:2px 0', line);
    button(help, 'Close', ()=> editor3DHelp = false, '?');

    editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
        properties, playFrom, storage, hint, help, typeNames: ''};
}

// show the panel as the editor is now
function editor3DPanelUpdate()
{
    if (!editor3DIsOpen)
    {
        editor3DPanel && (editor3DPanel.style.display = 'none');
        return;
    }
    editor3DPanel || editor3DPanelInit();
    editor3DPanel.style.display = '';
    const p = editor3DPanelParts, lit = '2px solid #4af';
    p.restart.style.display = levelEditor.onRestart ? '' : 'none';
    p.pending.style.display = editor3DRecords.get(editor3DLevel)?.pending ? '' : 'none';
    for (const tool in p.toolButtons)
        p.toolButtons[tool].style.outline = tool === editor3DTool ? lit : '';
    p.grid.checked = editor3DGrid;
    p.ground.checked = editor3DGroundSnap;
    p.moveStep.value = editor3DMoveStep + '';
    p.rotateStep.value = editor3DRotateStep + '';
    p.scaleStep.value = editor3DScaleStep + '';
    p.playFrom.checked = editor3DPlayFromMouse;
    p.playFrom.parentElement.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
    p.storage.style.display = editor3DSaveFailed ? '' : 'none';
    p.hint.textContent = editor3DHint();
    p.help.style.display = editor3DHelp ? '' : 'none';

    // a button for each type, made again when the types changed
    const names = [...level3DTypes.keys()], typeNames = names.join('\n');
    if (p.typeNames !== typeNames)
    {
        p.typeNames = typeNames;
        p.types.replaceChildren(...names.map((name)=>
        {
            const b = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', name);
            b.onclick = ()=> { editor3DBrush = editor3DBrush === name ? undefined : name; b.blur(); };
            return b;
        }));
    }
    names.forEach((name, i)=> p.types.children[i].style.outline = name === editor3DBrush ? lit : '');
    editor3DPropertiesUpdate(p.properties);
}

// the properties box: the position, rotation and scale of the one selected object and an input for each default
// of its type, made again when the selection or the object changes, but not while one of its inputs is typed in
function editor3DPropertiesUpdate(box)
{
    const selected = editor3DSelected(), object = selected.length === 1 && selected[0];
    const type = object && level3DTypes.get(object.type);
    const key = object ? JSON.stringify(object) : selected.length ? selected.length + ' objects' : '';
    box.style.display = key ? '' : 'none';
    if (box.dataset.key === key || box.contains(document.activeElement)) return;
    box.dataset.key = key;
    box.replaceChildren();
    if (!object)
    {
        key && editorElement('div', box, 'color:#aaa', key + ' selected');
        return;
    }
    editorElement('div', box, 'color:#aaa;margin-bottom:2px', `${object.type} ${object.id}`);
    const field = 'background:#222;color:#eee';
    const line = (name)=>
    {
        const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
        editorElement('span', row, 'flex:1', name);
        return row;
    };

    // the numbers of a vector, set together once every one of them reads as a number
    const numbers = (name, value, set)=>
    {
        const row = line(name), axes = isVector3(value) ? 'xyz' : 'xy', inputs = [];
        for (const axis of axes)
        {
            const input = editorElement('input', row, field + ';width:' + (axes.length > 2 ? 46 : 52) + 'px');
            input.type = 'number';
            input.step = 'any';
            input.value = editor3DRound(value[axis]) + '';
            input.onchange = ()=>
            {
                const v = inputs.map((i)=> parseFloat(i.value));
                if (v.every((n)=> isNumber(n)))
                    set(axes.length > 2 ? vec3(v[0], v[1], v[2]) : vec2(v[0], v[1]));
                else
                    inputs.forEach((i, k)=> i.value = editor3DRound(value[axes[k]]) + '');
                input.blur();
            };
            inputs.push(input);
        }
    };
    numbers('pos', editor3DPos(object), (v)=> editor3DSetSelectedTransform('pos', v));
    numbers('rotation', editor3DRotation(object), (v)=> editor3DSetSelectedTransform('rotation', v));
    numbers('scale', editor3DScale(object), (v)=> editor3DSetSelectedTransform('scale', v));
    if (!type) return;

    const values = level3DProperties(type, object);
    for (const [name, defaultValue] of Object.entries(type.defaults))
    {
        const value = values[name];
        if (isVector3(defaultValue) || isVector2(defaultValue))
        {
            numbers(name, value, (v)=> editor3DSetSelectedProperty(name, v));
            continue;
        }
        const input = editorElement('input', line(name), field + ';width:110px');
        const set = (v)=> { editor3DSetSelectedProperty(name, v); input.blur(); };
        if (typeof defaultValue === 'boolean')
        {
            input.type = 'checkbox';
            input.checked = !!value;
            input.onchange = ()=> set(input.checked);
        }
        else if (isColor(defaultValue))
        {
            input.type = 'color';
            input.value = value.toString(false);
            input.onchange = ()=> { const color = new Color().setHex(input.value); color.a = value.a; set(color); };
        }
        else if (typeof defaultValue === 'number')
        {
            input.type = 'number';
            input.step = 'any';
            input.value = value + '';
            input.onchange = ()=> { const v = parseFloat(input.value); isNumber(v) && set(v); };
        }
        else if (typeof defaultValue === 'string')
        {
            input.type = 'text';
            input.value = (value ?? '') + '';
            input.onchange = ()=> set(input.value);
        }
        else
        {
            // a type it has no input for, an array say, shown as it is
            input.readOnly = true;
            input.value = value + '';
        }
    }
}

///////////////////////////////////////////////////////////////////////////////
// plugin

function editor3DUpdate()
{
    const seconds = clamp(timeReal - editor3DTimeLast, 0, .1);
    editor3DTimeLast = timeReal;
    if (!render3D) return;
    if (editor3DFreeCamera)
        return inputCaptureRead(()=> editor3DFreeCameraUpdate(seconds));
    if (editor3DIsOpen)
        return inputCaptureRead(()=> editor3DWithView(()=> editor3DEditorUpdate(seconds)));

    // playing in an editing session, Escape, the debug key, goes back to the editor; taken, so the debug overlay
    // waits till the session ends; a captured mouse the browser let go of is Escape too, Chrome keeps that key
    const lostLock = editor3DLostLock(pointerLockIsActive());
    if (editor3DSession && (lostLock || debugKey && keyWasPressed(debugKey)))
    {
        debugKey && inputClearKey(debugKey);
        editor3DSetOpen(true);
        return;
    }
    editor3DFreeCameraKey();
}

function editor3DRender()
{
    if (headlessMode) return;
    editor3DPanelUpdate();
    if (editor3DFreeCamera)
        drawTextScreen('Free camera · C or Esc to leave', vec2(mainCanvasSize.x / 2, mainCanvasSize.y - 30), 20, WHITE,
            4, BLACK);
    editor3DIsOpen && editor3DDrawLabels();
}

debug && engineAddPlugin(editor3DUpdate, editor3DRender);
debug && debugOverlayKeys.push(()=> render3D && {text: 'C: Free Camera', on: editor3DFreeCamera});