XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
UTF-8
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
  <style>
    :root { color-scheme: light dark; font-family: "Segoe UI", sans-serif; }
    * { box-sizing: border-box; }
    html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #11151b; }
    canvas { display: block; width: 100%; height: 100%; outline: none; touch-action: none; }
    #toolbar {
      position: fixed; top: 10px; left: 10px; right: 10px; z-index: 4;
      display: flex; flex-wrap: wrap; align-items: center; gap: 5px; padding: 7px;
      border: 1px solid rgba(255,255,255,.13); border-radius: 9px;
      background: rgba(25,29,37,.88); backdrop-filter: blur(16px);
      box-shadow: 0 6px 24px rgba(0,0,0,.25);
    }
    button {
      min-width: 31px; height: 30px; padding: 0 9px; border: 0; border-radius: 5px;
      color: #ecf0f7; background: rgba(255,255,255,.09); font: inherit; font-size: 12px;
      cursor: pointer;
    }
    button:hover { background: rgba(96,169,255,.28); }
    button:active { background: rgba(96,169,255,.45); }
    button:disabled { opacity: .38; cursor: default; }
    .primary { background: #286fc5; }
    .danger:hover { background: #a43b47; }
    .separator { width: 1px; height: 22px; margin: 0 2px; background: rgba(255,255,255,.14); }
    .axis-x { color: #ff818d; } .axis-y { color: #7ee787; } .axis-z { color: #79c0ff; }
    #selection, #help, #message {
      position: fixed; z-index: 3; padding: 8px 11px; border-radius: 7px;
      color: #e8edf5; background: rgba(20,24,31,.78); backdrop-filter: blur(10px);
      pointer-events: none; font-size: 12px; line-height: 1.45;
    }
    #selection { left: 10px; bottom: 10px; max-width: min(720px, calc(100% - 20px)); }
    #help { right: 10px; bottom: 10px; color: #aeb8c8; text-align: right; }
    #message { left: 50%; top: 74px; transform: translateX(-50%); display: none; }
    #empty {
      position: fixed; inset: 0; display: grid; place-items: center; color: #aeb8c8;
      font-size: 16px; pointer-events: none;
    }
    @media (prefers-color-scheme: light) {
      html, body { background: #eef1f6; }
      #toolbar, #selection, #help, #message { color: #172033; background: rgba(250,252,255,.88); border-color: rgba(0,0,0,.1); }
      button { color: #172033; background: rgba(0,0,0,.07); }
      .primary { color: white; }
      #help, #empty { color: #536176; }
    }
  </style>
</head>
<body>
  <canvas id="view" tabindex="0"></canvas>
  <div id="toolbar">
    <button class="primary" data-action="save" data-i18n="save">Save</button>
    <button data-action="saveAs" data-i18n="saveAs">Save as</button>
    <div class="separator"></div>
    <button data-action="undo" title="Ctrl+Z">↶</button>
    <button data-action="redo" title="Ctrl+Y">↷</button>
    <div class="separator"></div>
    <button class="axis-x" data-action="moveXNegative" data-selected title="X -1">X−</button>
    <button class="axis-x" data-action="moveXPositive" data-selected title="X +1">X+</button>
    <button class="axis-y" data-action="moveYNegative" data-selected title="Y -1">Y−</button>
    <button class="axis-y" data-action="moveYPositive" data-selected title="Y +1">Y+</button>
    <button class="axis-z" data-action="moveZNegative" data-selected title="Z -1">Z−</button>
    <button class="axis-z" data-action="moveZPositive" data-selected title="Z +1">Z+</button>
    <div class="separator"></div>
    <button class="axis-x" data-action="rotateX" data-selected title="Rotate X 90°">R·X</button>
    <button class="axis-y" data-action="rotateY" data-selected title="Rotate Y 90°">R·Y</button>
    <button class="axis-z" data-action="rotateZ" data-selected title="Rotate Z 90°">R·Z</button>
    <button data-action="duplicate" data-selected title="Shift+D" data-i18n="duplicate">Duplicate</button>
    <button class="danger" data-action="delete" data-selected title="Delete" data-i18n="delete">Delete</button>
    <div class="separator"></div>
    <button id="fit" title="F" data-i18n="fit">Frame all</button>
  </div>
  <div id="message"></div>
  <div id="selection"></div>
  <div id="help"></div>
  <div id="empty"></div>

  <script>
  (() => {
    'use strict';
    const isZh = (navigator.language || '').toLowerCase().startsWith('zh');
    const text = isZh ? {
      save: '保存', saveAs: '另存为', duplicate: '复制', delete: '删除', fit: '适应全部',
      noSelection: '未选择方块', empty: '加载蓝图三维模型…', unsupported: '当前环境不支持 WebGL 2',
      help: '左键选择/旋转 · Shift+拖动平移 · 滚轮缩放 · 方向键/PageUp/PageDown 移动 · Delete 删除 · Shift+D 复制 · Ctrl+S 保存',
      blocks: '方块', position: '坐标', grid: '网格'
    } : {
      save: 'Save', saveAs: 'Save as', duplicate: 'Duplicate', delete: 'Delete', fit: 'Frame all',
      noSelection: 'No block selected', empty: 'Loading blueprint model…', unsupported: 'WebGL 2 is not available',
      help: 'Click select/orbit · Shift+drag pan · Wheel zoom · Arrows/PageUp/PageDown move · Delete · Shift+D · Ctrl+S',
      blocks: 'blocks', position: 'Position', grid: 'Grid'
    };
    document.querySelectorAll('[data-i18n]').forEach(e => e.textContent = text[e.dataset.i18n]);
    const canvas = document.getElementById('view');
    const selectionElement = document.getElementById('selection');
    const emptyElement = document.getElementById('empty');
    const helpElement = document.getElementById('help');
    const messageElement = document.getElementById('message');
    helpElement.textContent = text.help;
    emptyElement.textContent = text.empty;
    selectionElement.textContent = text.noSelection;

    const gl = canvas.getContext('webgl2', { antialias: true, alpha: false, preserveDrawingBuffer: false });
    if (!gl) {
      emptyElement.textContent = text.unsupported;
      return;
    }

    const blockVertexSource = `#version 300 es
      precision highp float;
      layout(location=0) in vec3 aPosition;
      layout(location=1) in vec3 aNormal;
      layout(location=2) in vec3 iCenter;
      layout(location=3) in vec3 iSize;
      layout(location=4) in vec3 iColor;
      layout(location=5) in vec3 iAxisX;
      layout(location=6) in vec3 iAxisY;
      layout(location=7) in vec3 iAxisZ;
      layout(location=8) in float iId;
      uniform mat4 uViewProjection;
      uniform float uSelected;
      out vec3 vColor;
      out vec3 vNormal;
      flat out int vId;
      void main() {
        vec3 scaled = aPosition * iSize;
        vec3 world = iCenter + iAxisX * scaled.x + iAxisY * scaled.y + iAxisZ * scaled.z;
        gl_Position = uViewProjection * vec4(world, 1.0);
        vNormal = normalize(iAxisX * aNormal.x + iAxisY * aNormal.y + iAxisZ * aNormal.z);
        vColor = abs(iId - uSelected) < 0.1 ? mix(iColor, vec3(1.0, 0.72, 0.12), 0.72) : iColor;
        vId = int(iId) + 1;
      }`;
    const blockFragmentSource = `#version 300 es
      precision highp float;
      in vec3 vColor;
      in vec3 vNormal;
      flat in int vId;
      uniform bool uPicking;
      out vec4 outColor;
      void main() {
        if (uPicking) {
          int value = vId;
          outColor = vec4(float(value & 255), float((value >> 8) & 255), float((value >> 16) & 255), 255.0) / 255.0;
          return;
        }
        vec3 light = normalize(vec3(0.45, 0.82, 0.36));
        float diffuse = max(dot(normalize(vNormal), light), 0.0);
        float edge = 0.32 + diffuse * 0.68;
        outColor = vec4(vColor * edge, 1.0);
      }`;
    const lineVertexSource = `#version 300 es
      precision highp float;
      layout(location=0) in vec3 aPosition;
      layout(location=1) in vec4 aColor;
      uniform mat4 uViewProjection;
      out vec4 vColor;
      void main() { gl_Position = uViewProjection * vec4(aPosition, 1.0); vColor = aColor; }`;
    const lineFragmentSource = `#version 300 es
      precision highp float;
      in vec4 vColor; out vec4 outColor;
      void main() { outColor = vColor; }`;

    function shader(type, source) {
      const value = gl.createShader(type); gl.shaderSource(value, source); gl.compileShader(value);
      if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(value));
      return value;
    }
    function program(vertex, fragment) {
      const value = gl.createProgram();
      gl.attachShader(value, shader(gl.VERTEX_SHADER, vertex));
      gl.attachShader(value, shader(gl.FRAGMENT_SHADER, fragment));
      gl.linkProgram(value);
      if (!gl.getProgramParameter(value, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(value));
      return value;
    }

    const blockProgram = program(blockVertexSource, blockFragmentSource);
    const lineProgram = program(lineVertexSource, lineFragmentSource);
    const blockVao = gl.createVertexArray();
    const lineVao = gl.createVertexArray();
    const instanceBuffers = [];
    let instanceCount = 0;
    let scene = null;
    let selectedId = -1;
    let boundsCenter = [0, 0, 0];
    let boundsRadius = 10;
    let yaw = 0.72, pitch = 0.42, distance = 25, target = [0, 0, 0];
    let pointer = null;
    let moved = false;
    let frameBuffer = null, pickTexture = null, depthBuffer = null, frameWidth = 0, frameHeight = 0;

    function cubeVertices() {
      const faces = [
        [[ 1,0,0], [[.5,-.5,-.5],[.5,.5,-.5],[.5,.5,.5],[.5,-.5,.5]]],
        [[-1,0,0], [[-.5,-.5,.5],[-.5,.5,.5],[-.5,.5,-.5],[-.5,-.5,-.5]]],
        [[0, 1,0], [[-.5,.5,-.5],[-.5,.5,.5],[.5,.5,.5],[.5,.5,-.5]]],
        [[0,-1,0], [[-.5,-.5,.5],[-.5,-.5,-.5],[.5,-.5,-.5],[.5,-.5,.5]]],
        [[0,0, 1], [[-.5,-.5,.5],[.5,-.5,.5],[.5,.5,.5],[-.5,.5,.5]]],
        [[0,0,-1], [[.5,-.5,-.5],[-.5,-.5,-.5],[-.5,.5,-.5],[.5,.5,-.5]]]
      ];
      const data = [];
      for (const [normal, corners] of faces) {
        for (const index of [0,1,2,0,2,3]) data.push(...corners[index], ...normal);
      }
      return new Float32Array(data);
    }

    gl.bindVertexArray(blockVao);
    const cubeBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, cubeBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, cubeVertices(), gl.STATIC_DRAW);
    gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0);
    gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12);
    for (let location = 2; location <= 8; location++) {
      const buffer = gl.createBuffer(); instanceBuffers.push(buffer); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
      const length = location === 8 ? 1 : 3;
      gl.enableVertexAttribArray(location); gl.vertexAttribPointer(location, length, gl.FLOAT, false, 0, 0);
      gl.vertexAttribDivisor(location, 1);
    }
    gl.bindVertexArray(null);

    function uploadInstances(blocks) {
      instanceCount = blocks.length;
      const values = Array.from({ length: 7 }, (_, index) => new Float32Array(blocks.length * (index === 6 ? 1 : 3)));
      blocks.forEach((block, index) => {
        values[0].set(block.p, index * 3); values[1].set(block.s, index * 3); values[2].set(block.c, index * 3);
        values[3].set(block.x, index * 3); values[4].set(block.y, index * 3); values[5].set(block.z, index * 3);
        values[6][index] = block.i;
      });
      values.forEach((value, index) => {
        gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffers[index]);
        gl.bufferData(gl.ARRAY_BUFFER, value, gl.DYNAMIC_DRAW);
      });
      updateBounds(blocks);
      updateGrid();
    }

    function updateBounds(blocks) {
      if (!blocks.length) { boundsCenter = [0,0,0]; boundsRadius = 10; return; }
      const min = [Infinity,Infinity,Infinity], max = [-Infinity,-Infinity,-Infinity];
      for (const block of blocks) for (let i=0;i<3;i++) {
        const half = Math.max(block.s[0], block.s[1], block.s[2]) * .5;
        min[i] = Math.min(min[i], block.p[i] - half); max[i] = Math.max(max[i], block.p[i] + half);
      }
      boundsCenter = min.map((v,i) => (v + max[i]) * .5);
      boundsRadius = Math.max(1, Math.hypot(max[0]-min[0], max[1]-min[1], max[2]-min[2]) * .5);
    }

    function updateGrid() {
      const extent = Math.max(10, Math.ceil(boundsRadius / 5) * 5);
      const rough = extent / 18;
      const power = Math.pow(10, Math.floor(Math.log10(Math.max(rough, .1))));
      const step = [1,2,5,10].map(v => v*power).find(v => v >= rough) || 10*power;
      const lines = [];
      const add = (a,b,c) => lines.push(...a,...c,...b,...c);
      const gridColor = [0.46,0.52,0.62,0.22];
      for (let value = -extent; value <= extent + .001; value += step) {
        add([value,0,-extent],[value,0,extent],gridColor);
        add([-extent,0,value],[extent,0,value],gridColor);
      }
      add([-extent,0,0],[extent,0,0],[.95,.24,.3,.9]);
      add([0,-extent,0],[0,extent,0],[.25,.88,.38,.9]);
      add([0,0,-extent],[0,0,extent],[.2,.55,1,.9]);
      gl.bindVertexArray(lineVao);
      const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(lines), gl.DYNAMIC_DRAW);
      gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,3,gl.FLOAT,false,28,0);
      gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,4,gl.FLOAT,false,28,12);
      lineVao.count = lines.length / 7;
      gl.bindVertexArray(null);
    }

    function setScene(value) {
      const firstScene = !scene;
      scene = value;
      selectedId = Number.isInteger(value.selected) ? value.selected : -1;
      uploadInstances(value.blocks || []);
      emptyElement.style.display = value.blocks?.length ? 'none' : 'grid';
      if (!value.blocks?.length) emptyElement.textContent = `${value.name || ''} · 0 ${text.blocks}`;
      if (firstScene) frameAll();
      updateSelection();
      render();
    }

    function updateSelection() {
      const block = scene?.blocks?.find(value => value.i === selectedId);
      document.querySelectorAll('[data-selected]').forEach(button => button.disabled = !block);
      if (!block) { selectionElement.textContent = scene ? `${scene.name} · ${scene.blocks.length} ${text.blocks}` : text.noSelection; return; }
      const type = scene.types[block.t] || {};
      const grid = scene.grids[block.g] || `${text.grid} ${block.g + 1}`;
      selectionElement.textContent = `${type.name || type.subtype} · ${grid} · ${text.position} (${block.m.join(', ')})`;
    }

    function send(message) {
      if (window.chrome?.webview) window.chrome.webview.postMessage(message);
    }
    function action(name) { send({ type: 'action', action: name }); }
    document.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', () => action(button.dataset.action)));
    document.getElementById('fit').addEventListener('click', frameAll);

    function frameAll() {
      target = [...boundsCenter]; distance = Math.max(4, boundsRadius * 2.45); yaw = .72; pitch = .42; render();
    }
    function frameSelected() {
      const block = scene?.blocks?.find(value => value.i === selectedId);
      if (!block) return frameAll();
      target = [...block.p]; distance = Math.max(3, Math.max(...block.s) * 4); render();
    }

    function perspective(fovy, aspect, near, far) {
      const f = 1 / Math.tan(fovy/2), nf = 1/(near-far);
      return new Float32Array([f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0]);
    }
    function lookAt(eye, center, up) {
      let z = normalize(subtract(eye,center)), x = normalize(cross(up,z)), y = cross(z,x);
      return new Float32Array([x[0],y[0],z[0],0, x[1],y[1],z[1],0, x[2],y[2],z[2],0,
        -dot(x,eye),-dot(y,eye),-dot(z,eye),1]);
    }
    function multiply(a,b) {
      const out = new Float32Array(16);
      for(let column=0;column<4;column++) for(let row=0;row<4;row++)
        out[column*4+row] = a[row]*b[column*4] + a[4+row]*b[column*4+1] + a[8+row]*b[column*4+2] + a[12+row]*b[column*4+3];
      return out;
    }
    const subtract=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]];
    const add=(a,b)=>[a[0]+b[0],a[1]+b[1],a[2]+b[2]];
    const scale=(a,s)=>[a[0]*s,a[1]*s,a[2]*s];
    const dot=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
    const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
    const normalize=a=>{const l=Math.hypot(...a)||1;return a.map(v=>v/l)};

    function camera() {
      const cp=Math.cos(pitch), eye=[target[0]+distance*cp*Math.sin(yaw),target[1]+distance*Math.sin(pitch),target[2]+distance*cp*Math.cos(yaw)];
      const view=lookAt(eye,target,[0,1,0]);
      const projection=perspective(Math.PI/4,Math.max(1,canvas.width)/Math.max(1,canvas.height),Math.max(.02,distance-boundsRadius*1.5),distance+boundsRadius*4+100);
      return { eye, matrix:multiply(projection,view) };
    }

    function resize() {
      const ratio=Math.min(devicePixelRatio||1,2), width=Math.max(1,Math.floor(canvas.clientWidth*ratio)), height=Math.max(1,Math.floor(canvas.clientHeight*ratio));
      if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height;resizePickBuffer(width,height);}
    }
    function render(picking=false) {
      resize(); const cam=camera();
      gl.bindFramebuffer(gl.FRAMEBUFFER,picking?frameBuffer:null); gl.viewport(0,0,canvas.width,canvas.height);
      gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.cullFace(gl.BACK);
      if(picking) gl.clearColor(0,0,0,1); else gl.clearColor(.055,.071,.094,1);
      gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
      if(!picking){
        gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
        gl.useProgram(lineProgram); gl.uniformMatrix4fv(gl.getUniformLocation(lineProgram,'uViewProjection'),false,cam.matrix);
        gl.bindVertexArray(lineVao); gl.drawArrays(gl.LINES,0,lineVao.count||0); gl.disable(gl.BLEND);
      }
      if(instanceCount){
        gl.useProgram(blockProgram); gl.uniformMatrix4fv(gl.getUniformLocation(blockProgram,'uViewProjection'),false,cam.matrix);
        gl.uniform1f(gl.getUniformLocation(blockProgram,'uSelected'),selectedId); gl.uniform1i(gl.getUniformLocation(blockProgram,'uPicking'),picking?1:0);
        gl.bindVertexArray(blockVao); gl.drawArraysInstanced(gl.TRIANGLES,0,36,instanceCount);
      }
      gl.bindVertexArray(null); gl.bindFramebuffer(gl.FRAMEBUFFER,null);
    }

    function resizePickBuffer(width,height){
      if(frameBuffer&&frameWidth===width&&frameHeight===height)return;
      frameWidth=width;frameHeight=height;
      if(frameBuffer){gl.deleteFramebuffer(frameBuffer);gl.deleteTexture(pickTexture);gl.deleteRenderbuffer(depthBuffer);}
      frameBuffer=gl.createFramebuffer();gl.bindFramebuffer(gl.FRAMEBUFFER,frameBuffer);
      pickTexture=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,pickTexture);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST);
      gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA8,width,height,0,gl.RGBA,gl.UNSIGNED_BYTE,null);gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,pickTexture,0);
      depthBuffer=gl.createRenderbuffer();gl.bindRenderbuffer(gl.RENDERBUFFER,depthBuffer);gl.renderbufferStorage(gl.RENDERBUFFER,gl.DEPTH_COMPONENT16,width,height);gl.framebufferRenderbuffer(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.RENDERBUFFER,depthBuffer);
      gl.bindFramebuffer(gl.FRAMEBUFFER,null);
    }
    function pick(clientX,clientY){
      render(true);const ratio=canvas.width/canvas.clientWidth,pixel=new Uint8Array(4),x=Math.max(0,Math.min(canvas.width-1,Math.floor(clientX*ratio))),y=Math.max(0,Math.min(canvas.height-1,canvas.height-1-Math.floor(clientY*ratio)));
      gl.bindFramebuffer(gl.FRAMEBUFFER,frameBuffer);gl.readPixels(x,y,1,1,gl.RGBA,gl.UNSIGNED_BYTE,pixel);gl.bindFramebuffer(gl.FRAMEBUFFER,null);
      const value=pixel[0]+(pixel[1]<<8)+(pixel[2]<<16);selectedId=value?value-1:-1;updateSelection();send({type:'select',id:selectedId});render();
    }

    canvas.addEventListener('pointerdown',event=>{canvas.setPointerCapture(event.pointerId);pointer={x:event.clientX,y:event.clientY,button:event.button,shift:event.shiftKey};moved=false;canvas.focus();});
    canvas.addEventListener('pointermove',event=>{
      if(!pointer)return;const dx=event.clientX-pointer.x,dy=event.clientY-pointer.y;if(Math.abs(dx)+Math.abs(dy)>2)moved=true;
      if(pointer.shift||pointer.button===1){const cam=camera(),forward=normalize(subtract(target,cam.eye)),right=normalize(cross(forward,[0,1,0])),up=normalize(cross(right,forward)),amount=distance*.0015;target=add(target,add(scale(right,-dx*amount),scale(up,dy*amount)));}
      else{yaw-=dx*.007;pitch=Math.max(-1.48,Math.min(1.48,pitch+dy*.007));}
      pointer.x=event.clientX;pointer.y=event.clientY;render();
    });
    canvas.addEventListener('pointerup',event=>{if(pointer&&!moved&&pointer.button===0)pick(event.offsetX,event.offsetY);pointer=null;});
    canvas.addEventListener('pointercancel',()=>pointer=null);
    canvas.addEventListener('contextmenu',event=>event.preventDefault());
    canvas.addEventListener('wheel',event=>{event.preventDefault();distance=Math.max(.5,Math.min(1e7,distance*Math.exp(event.deltaY*.001)));render();},{passive:false});
    canvas.addEventListener('dblclick',frameSelected);
    window.addEventListener('resize',render);
    window.addEventListener('keydown',event=>{
      if(event.ctrlKey&&event.key.toLowerCase()==='s'){event.preventDefault();action('save');return;}
      if(event.ctrlKey&&event.key.toLowerCase()==='z'){event.preventDefault();action('undo');return;}
      if(event.ctrlKey&&event.key.toLowerCase()==='y'){event.preventDefault();action('redo');return;}
      if(event.shiftKey&&event.key.toLowerCase()==='d'){event.preventDefault();action('duplicate');return;}
      const actions={Delete:'delete',ArrowLeft:'moveXNegative',ArrowRight:'moveXPositive',ArrowUp:'moveZNegative',ArrowDown:'moveZPositive',PageUp:'moveYPositive',PageDown:'moveYNegative'};
      if(actions[event.key]&&selectedId>=0){event.preventDefault();action(actions[event.key]);return;}
      if(event.key.toLowerCase()==='f'){event.preventDefault();frameSelected();}
      if(event.key==='Escape'){selectedId=-1;updateSelection();send({type:'select',id:-1});render();}
    });

    if(window.chrome?.webview){
      window.chrome.webview.addEventListener('message',event=>{if(event.data?.type==='scene')setScene(event.data);});
      send({type:'ready'});
    } else if(new URLSearchParams(location.search).has('demo')) {
      const demoBlocks=[]; let demoId=0;
      const addDemo=(x,y,z,sx=1,sy=1,sz=1,color=[.43,.55,.68],type=0)=>demoBlocks.push({i:demoId++,g:0,t:type,p:[x,y,z],s:[sx,sy,sz],x:[1,0,0],y:[0,1,0],z:[0,0,1],c:color,m:[x,y,z]});
      for(let z=-10;z<=10;z++) for(let x=-3;x<=3;x++) for(let y=-1;y<=1;y++) if(Math.abs(x)+Math.abs(y)<4&&!(Math.abs(x)<2&&y===0&&z>-7)) addDemo(x,y,z);
      for(let x=4;x<=13;x++) { addDemo(x,0,2,1,.45,4,[.3,.48,.72]); addDemo(-x,0,2,1,.45,4,[.3,.48,.72]); }
      addDemo(0,0,-12,3,3,4,[.72,.42,.2],1); addDemo(0,1.5,5,3,2,4,[.22,.56,.72],2);
      setScene({type:'scene',name:'WebGL editor demo',grids:['Demo grid'],types:[{name:'Light Armor Block',subtype:'LargeBlockArmorBlock'},{name:'Large Reactor',subtype:'LargeBlockLargeGenerator'},{name:'Cockpit',subtype:'LargeBlockCockpit'}],blocks:demoBlocks,selected:null});
    } else {
      messageElement.textContent='WebView bridge unavailable';messageElement.style.display='block';
    }
    render();
  })();
  </script>
</body>
</html>