VR模組開發教學


[info] 小提示:

範例網站連結:https://data-3dgdp.colife.org.tw/VROOM/


依賴套件

本教學的範例站台使用了以下的套件:

sweetalert2: 用來顯示提示訊息的套件,詳見官方網站

cuon-matrix.js: 用來處理矩陣運算的WebGL套件,詳見來源

請參考官方網站或教學中 index.html 的引入方式。

初始化VR

VR初始化須依賴核心生成的div,所以需要寫在地形開啟後的CallBack中。

[info] 特別提醒:

若是系統沒有接上VR設備(或是沒有安裝模擬器),在初始化VR時會將按鈕狀態設為disabled無法點擊進入。

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <!-- CSS  -->
    <link href="./css/PGWeb3D.css" rel="stylesheet" type="text/css" />
    <link rel="stylesheet" href="./css/styles.css">
    <link rel="stylesheet" href="./css/sweetalert2.css">
    <!-- JS -->
    <script src="./js/sweetalert2.all.js"></script>
    <script src="./js/PGWeb3D.min.js"></script>
    <script src="./js/cuon-matrix.js"></script>
    <script src="./js/CameraQuatenion.js"></script>
    <script src="./js/menuHandle.js"></script>

</head>
<body>
    <div id="bodyContainer">
        <div id="ToolContainer">
            <div id="userName" onclick="setUserName()">設定您的名稱</div>
            <button id="vr" disabled>VR Unavailable</button>
            <div class="btnFunc">
                <span id="btnCreateRoom" onclick="createRoom()">建立會議</span>
            </div>
            <button id="btnJoinRoom" class="btnFunc" onclick="joinRoom()">加入會議</button>
        </div>
        <div id="MapContainer">

        </div>
    </div>
</body>
<script src="./js/main.js"></script>
</html>

main.js

管理整個 XR 多人連線會議室,包含VR按鈕的初始化、連線的建立、加入、離開等功能。

// main_improved.js

// 引入必要的庫和工具
const Swal = window.Swal;
const ov = window.ov;
const GeoPoint = window.GeoPoint;
const Geo3DPoint = window.Geo3DPoint;
const GeoPolyline = window.GeoPolyline;

// 初始化圖台
var terrainview = new ov.TerrainView("MapContainer");

// 設定全域變數 terrainview,方便之後在瀏覽器的 Console 來 Debug
window.terrainview = terrainview;

// Connection Info
const hostUrl = window.location.origin + window.location.pathname; // 網站的 URL

// 設定伺服器的 WebSocket URL
const socketServerUrl = "vroom.colife.org.tw";
const socketServerPort = "30076";

// 圖層
var pointerLayer; // 圖標圖層

// 變數
var userName; // 預設使用者名稱
var userDevice; // 使用者裝置
var createSymbolStatus = false; // 圖標功能狀態
var meetRoom = null; // 會議室
var guests = new Map(); // 訪客列表
var cameraUpdateInterval = 41.6; // 相機更新間隔, 41.6ms 約 24fps
var cameraUpdateIntervalHandle = null; // 相機更新間隔的 handle
var isVRMode = false; // 是否為 VR 模式
const EARTH_RADIUS = 6371008.8; // 地球半徑(公尺)

const buttonVR = document.getElementById("vr");

// 開源底圖資源集合
const baseLayerResource = {
  bing_i: { url: "BING_MAP", identifier: "IMAGE" },
  bing_v: { url: "BING_MAP", identifier: "VECTOR" },
  bing_vi: { url: "BING_MAP", identifier: "VECTOR_IMAGE" },
  nlsc_e: { url: "https://wmts.nlsc.gov.tw/wmts", identifier: "EMAP" },
  nlsc_eg: { url: "https://wmts.nlsc.gov.tw/wmts", identifier: "EMAP01" },
  nlsc_p: { url: "https://wmts.nlsc.gov.tw/wmts", identifier: "PHOTO2" },
  osm: { url: "OSM", identifier: "" },
};

terrainview.openTerrain({
  url: "https://data-3dgdp.colife.org.tw/oviewRP.ashx",
  identifier: "terrain",
  callback: openTerrainCallback,
});

// 地形開啟後的 callback
function openTerrainCallback(terrain, success) {
  // 設定底圖
  let result = terrainview.setBaseLayer(baseLayerResource.nlsc_e);

  // 關閉光源
  terrainview.enableLight = false;

  // 關閉面板
  terrainview.controlPanel = false;

  // terrainview event
  terrainview.addEventListener("MouseDown", handleMouseDown);

  // mobile touch event
  document.addEventListener("touchend", handleTouchEnd);

  // Layers
  pointerLayer = terrainview.addCustomLayer("pointerLayer");  

  // 設定初始相機位置
  initCamera();

  // websocket
  connectWebSocket();

  // VR
  watchVRButton();

  document.addEventListener("keyup", handleKeyUp);

  // 初始化VR,需在https或localhost下,
  // 如果系統支援VR,按鈕會變的可按。
  // @param {HTMLButtonElement} buttonElement 按鈕元件。
  // @param {function} callback VR啟動後的回呼函式。
  terrainview.initVR(buttonVR, initVRSuccess);
}



// 檢查會議室狀態
async function meetRoomCheck() {
  const urlParams = new URLSearchParams(window.location.search);
  const roomId = urlParams.get("room");

  if (roomId && roomId.length === 32) {
    console.log("meetRoomCheck-roomId:", roomId);
    while (!userName) {
      await setUserName();
      if (!userName) { saAlert("請先設定您的使用者名稱"); }
    }
    joinRoom(roomId);
  }
}

// 檢查裝置類型
function deviceCheck() {
  let userDevice = "";
  if (isVRMode) {
    userDevice = "vr";
  } else if (navigator.userAgent.match(/(iPhone|iPod|iPad|Android|ios|SymbianOS)/i)) {
    userDevice = "mobile";
    terrainview.controlPanel = true;
  } else {
    userDevice = "pc";
  }
  return userDevice;
}

// 監聽 VR 按鈕
function watchVRButton() {
  let button = document.querySelector('button#vr');

  let observer = new MutationObserver(function (mutations) {
    mutations.forEach(function (mutation) {
      if (mutation.attributeName === 'disabled') {
        let isDisabled = button.getAttribute('disabled');
        if (!isDisabled) {
          console.log('Button has become enabled');
          button.innerText = "啟用VR模式";
        }
      }
    });
  });

  let config = { attributes: true };
  observer.observe(button, config);
}

// 初始化相機位置
function initCamera(fly = false) {
  let initialPos = new GeoPoint(120.2987197002847, 22.664748230709762, 25);
  let initialV = new Geo3DPoint(0, 0, -1);
  let initialUp = new Geo3DPoint(0, 1, 0);

  let initialCamera = new ov.Camera(initialPos, initialV, initialUp);
  terrainview.gotoCamera(initialCamera, fly);
}

/**
 * 建立圖標
 * @param {Object} pos 圖標位置
 * @param {string} name 圖標名稱
 * @param {boolean} isSelf 是否為自己建立的圖標
 */
function createSymbol(pos, name, isSelf = true) {
  customSymbol = pointerLayer.addPointEntity({
    geo: new GeoPoint(pos.x, pos.y, 10),
    color: new ov.Color([255, 0, 0]),
    absHeight: false,
    symbolFixOnXYPlane: true,
    size: 16,
    label: {
      text: `[${isSelf ? userName : name}]\n${getTimestampTag()}`,
      color: new ov.Color([200, 50, 50]),
      borderColor: new ov.Color([255, 255, 255]),
      size: 16,
    },
  });

  customSymbol.setTooltip(`經度: ${pos.y}<br>緯度: ${pos.x}`);

  if (isSelf) {
    let message = { type: "createSymbol", name: userName, pos: pos };
    sendWebSocketMessage(JSON.stringify(message));
  }
}

// 取得時間戳記
function getTimestampTag() {
  return (
    "<" +
    new Date()
      .toLocaleString("tw", { hour12: false })
      .replaceAll("/", "")
      .replace(" ", "-")
      .substr(9) +
    ">"
  );
}

// 設定建立圖標功能為開啟
function createSymbolHandler() {
  createSymbolStatus = true;
}

// 開始傳送相機資訊
function startSendMyCameraHandler() {
  sendMyCamera();
  sendMyUserDeviceType();
  cameraUpdateIntervalHandle = setInterval(sendMyCamera, cameraUpdateInterval);
}

// 停止傳送相機資訊
function stopSendMyCameraHandler() {
  clearInterval(cameraUpdateIntervalHandle);
}

// 傳送相機資訊
function sendMyCamera() {
  let cameraInfo = getCamaeraInfo();
  let message = {
    type: "updateCamera",
    payload: { user: userName, id: meetRoom.id, camera: cameraInfo },
  };
  if (isVRMode) {
    const inputs = terrainview.getXRViewInformation().inputs;
    if (inputs && inputs.length > 0) message.payload.controller = terrainview.getXRViewInformation().inputs;
  }
  sendWebSocketMessage(JSON.stringify(message));
}

// 傳送使用者裝置類型
function sendMyUserDeviceType() {
  userDevice = deviceCheck();
  console.log('sendMyUserDeviceType:', userDevice);
  let message = {
    type: "setUserDeviceType",
    payload: { user: userName, id: meetRoom.id, userDevice: userDevice },
  };
  sendWebSocketMessage(JSON.stringify(message));
}

// 取得相機資訊
function getCamaeraInfo() {
  let { pos, v, up } = terrainview.camera;
  let cameraInfo = {
    position: [pos.x, pos.y, pos.z],
    v: [v.x, v.y, v.z],
    up: [up.x, up.y, up.z],
  };
  return cameraInfo;
}

/** WebSocket */

let ws;

// 連線 WebSocket
function connectWebSocket() {
  ws = new WebSocket(`wss://${socketServerUrl}:${socketServerPort}`);

  // WebSocket 連線成功事件
  ws.onopen = function (event) {
    console.log("WebSocket is open now.");
    meetRoomCheck();
  };

  // WebSocket 收到訊息事件
  ws.onmessage = handleWebSocketMessage;

  // WebSocket 連線關閉事件
  ws.onclose = function (event) {
    console.log("WebSocket is closed now.");
    saAlert("通訊伺服器連線已斷開", "error");
    stopSendMyCameraHandler();
  };

  // WebSocket 錯誤事件
  ws.onerror = function (event) {
    console.log("WebSocket error observed:", event);
  };
}

// 處理 WebSocket 收到的訊息
function handleWebSocketMessage(message) {
  if (isJSON(message.data?.toString())) {
    let data = JSON.parse(message.data.toString());

    switch (data.type) {
      case "createSymbol":
        createSymbol(data.pos, data.name, false);
        break;
      case "chat":
        console.log(data.from + ": " + data.payload);
        break;
      case "createRoom":
        !meetRoom && createRoomHandler(data.payload);
        break;
      case "joinRoom":
        joinRoomHandler(data.payload);
        break;
      case "sendMessage":
        break;
      case "command":
        runCommand(data.payload);
        break;
      default:
        console.log("unknown type");
        break;
    }
  }
}

// 送出 WebSocket 訊息
function sendWebSocketMessage(message) {
  ws.send(message);
}

// 創建房間
async function createRoom() {
  if (!userName) { saAlert("請先設定您的使用者名稱"); return; }

  let roomName = (await saPrompt("請輸入您想使用的房間名稱", "測試會議室")).value ?? "";

  if (roomName.length < 3 || roomName.length > 36) {
    saAlert("房間名稱長度最少需要 3 個字元,最多 36 個字元", "warning")
    return;
  }

  if (roomName) {
    roomName = roomName.replace(/</g, "&lt;").replace(/>/g, "&gt;");
  }

  sendWebSocketMessage(JSON.stringify({ type: "createRoom", payload: { roomName } }));
}

// 加入房間
async function joinRoom(id) {
  const roomId = id ?? (await saPrompt("請輸入房間代號", ""))?.value;

  if (!roomId || !roomId.match(/^[a-zA-Z0-9]{32}$/)) {
    saAlert("此房間代號不存在。", "warning");
    return;
  }

  let message = { type: "joinRoom", payload: { roomId } };

  sendWebSocketMessage(JSON.stringify(message));
}

// 處理加入房間的回應
function joinRoomHandler(data) {
  const { result } = data;
  if (result == 'success') {
    const { roomName, roomId } = data;
    meetRoom = new Room(roomName, roomId);
    console.log(`Join room success: ${roomName}(${roomId})`);
    saAlert(`您已成功加入 ${roomName}(${roomId})`, "success");
    document.getElementById("btnJoinRoom").hidden = true;
    document.getElementById("btnCreateRoom").innerText = "複製房間連結";
    document.getElementById("btnCreateRoom").onclick = copyRoomLink;
    startSendMyCameraHandler();
  } else {
    saAlert("加入房間失敗。", "warning");
  }
}

// 設定使用者名稱
async function setUserName() {
  const promptResult = (await saPrompt("請輸入你想顯示的使用者名稱", getRandomUserName())).value ?? "";
  if (!promptResult) { return }
  else if (promptResult.length < 3 || promptResult.length > 24) { saAlert("名稱長度最少需要 3 個字元,最多 24 個字元", "warning"); return }

  userName = promptResult;

  const nameEle = document.getElementById("userName")
  nameEle.display = "block";
  nameEle.innerHTML = `Hi ${userName}.`;
  nameEle.classList.add("named");

  ws.send(JSON.stringify({ type: "setUserName", payload: { name: userName } }));

  return userName;
}

// 執行指令
function runCommand(command) {
  switch (command.cmd) {
    case "updateCamera":
      updateGuestCamera(command);
      break;
    case "setUserDeviceType":
      updateGuestDeviceType(command);
      break;
    case "createRoom":
      createRoomHandler(command.payload);
      break;
    case "guestLeaveRoom":
      guestLeaveRoomHandler(command.payload);
      break;
    default:
      console.log('command:', command)
      console.log("unknown command");
      break;
  }
}

// 更新訪客相機
function updateGuestCamera(command) {
  let { user, camera, controller } = command;
  if (meetRoom && !meetRoom.guests.has(user)) {
    createGuset(user, camera);
  }

  let guest = meetRoom.guests.get(user);
  guest.updateCamera(camera);

  if (controller) {
    guest.updateController(controller);
  }
}

// 更新訪客裝置類型
function updateGuestDeviceType(command) {
  let { user, userDevice } = command;
  if (meetRoom && !meetRoom.guests.has(user)) {
    return;
  }
  let guest = meetRoom.guests.get(user);
  console.log('updateGuestDeviceType:', user, userDevice);
  guest.updateDeviceType(userDevice);
}

// 創建房間的處理
function createRoomHandler(data) {
  const { result } = data;
  if (result) {
    const { roomName, roomId } = data;
    meetRoom = new Room(roomName, roomId);
    navigator.clipboard.writeText(meetRoom.roomUrl);
    console.log(`Create room success: ${roomName}(${roomId})`);
    saAlert(`房間建立成功: ${roomName}`, "success", `已複製房間連結`);
    document.getElementById("btnJoinRoom").hidden = true;
    document.getElementById("btnCreateRoom").innerText = "複製房間連結";
    document.getElementById("btnCreateRoom").onclick = copyRoomLink;
    startSendMyCameraHandler();
  } else {
    saAlert("房間建立失敗。", "warning");
  }
}

// 處理訪客離開房間的回應
function guestLeaveRoomHandler(data) {
  const { userName } = data;
  if (userName && meetRoom && meetRoom.guests.has(userName)) {
    meetRoom.removeGuest(userName);
    Toast.fire({ icon: "warning", title: `${userName} 離開房間` });
  }
}

// 創建訪客
function createGuset(name, camera, deviceType) {
  let guest = new Guest(name, camera, deviceType);
  meetRoom.addGuest(guest);
  Toast.fire({ icon: "success", title: `${name} 加入房間` });
}

// 複製房間連結
function copyRoomLink() {
  if (!meetRoom) {
    saAlert("請先加入或建立房間", "warning");
    return;
  }
  const roomUrl = meetRoom.roomUrl;
  navigator.clipboard.writeText(roomUrl);
  saAlert(`房間的加入連結已複製: ${roomUrl}`, "success");
}

class Guest {
  constructor(name, camera, deviceType) {
    this.name = name;
    this.camera = camera;
    this.cameraModel = null;
    this.minCameraScale = 0.2;
    this.maxCameraScale = 32.2;
    this.maxCameraScaleDistance = 5000;
    this.baseSize = 10064.412132311258;
    this.modelLayer = null;
    this.deviceType = deviceType || "pc";
    this.deviceIcon = {
      "vr": "🥽",
      "mobile": "📱",
      "pc": "💻",
    }
    this.controllerLeft = null;
    this.controllerRight = null;

    this.initModel();
  }

  initModel() {
    console.log("initModel");
    this.createCustomLayer();
    console.log("createCustomLayer");
    this.createModel();
    console.log("createModel");
  }

  createCustomLayer() {
    this.modelLayer = terrainview.addCustomLayer(
      this.name + Math.trunc(Math.random() * 1000)
    );
  }

  createModel() {
    if (this.modelLayer) {
      let { position: pos } = this.camera;
      console.log(`pos`, pos);
      this.cameraModel = this.modelLayer.addGLTFEntity({
        src: `${hostUrl}src/model/VRglass_F_X.glb`,
        position: new GeoPoint(...pos),
        minRange: 5,
        labelOffset: new GeoPoint(0, 3),
        scale: 0.2,
        label: {
          text: `${this.deviceIcon[this.deviceType]} ${this.name}`,
          worldSize: true,
          size: 0.8,
          color: new ov.Color("blue"),
          fontSize: 120,
          font: "arial",
          borderColor: new ov.Color("white"),
          borderSize: 12,
        },
      });

      if (this.name === "November-287") {
        window.g = this;
      }
    }
  }

  createController(controllerInfo) {
    if (this.modelLayer) {
      let { handedness: hand, worldPosition: pos, worldUp: up, worldView: v } = controllerInfo;

      console.log(`try to createController(${hand})`);
      console.log(`controller:`, controllerInfo)

      if (hand === "left") { this.controllerLeft = {}; }
      if (hand === "right") { this.controllerRight = {}; }
      const currentController = hand === "left" ? this.controllerLeft : this.controllerRight;

      currentController.model = this.modelLayer.addGLTFEntity({
        src: `${hostUrl}src/model/vr_handle_from_blob.glb`,
        position: new GeoPoint(pos),
        scale: 0.2,
        minRange: 5,
      });

      currentController.lineLength = 50;
      currentController.lineWidth = 2;
      currentController.centerPoint = new GeoPoint(pos);
      currentController.TargetPoint = getTranslatedPointByVector(currentController.centerPoint, v, currentController.lineLength);
      currentController.lineColor = hand == 'left' ? new ov.Color('00AA00') : new ov.Color('FF4444');
      currentController.line = this.modelLayer.addPolylineEntity({
        geo: new GeoPolyline([currentController.centerPoint, currentController.TargetPoint]),
        color: currentController.lineColor,
        size: currentController.lineWidth
      })

      console.log(`createController(${hand}) success`);
      console.log(`currentController:`, currentController.model.getParameter().src);
    }
  }

  // 計算當前 Guest 相機模型的顯示尺寸 (pixel)
  getCameraScreenSize() {
    return this.cameraModel._entity._CalcScreenSize(terrainview._TerrainEngine.MainCameraFrustum, terrainview._TerrainEngine.Device);
  }

  // 計算當前相機與 Guest 的距離
  getDistance() {
    return calcPointGeoDistance(terrainview.camera.pos, this.cameraModel._entity._OriginalGeo);
  }

  // 根據當前與Guest的距離、Guest的相機模型實際顯示的 pixel 尺寸及 Guest 的相機模型的 scale 計算出新的基礎尺寸
  setBaseSizeAsCurrentView() {
    this.baseSize = (this.getCameraScreenSize * this.getDistance) / this.getScale();
    return this.baseSize;
  }

  setControllerModel(model) {
    this.controllerLeft.model.update({ src: `${hostUrl}src/model/${model}` });
  }

  getScale() {
    return this.cameraModel._entity._Scale;
  }

  setScale(scale) {
    this.cameraModel.update({ scale });
  }

  calcScale() {
    let distance = this.getDistance();
    let size = 65;
    let scale = (size * distance) / this.baseSize;
    if (scale < this.minCameraScale) scale = this.minCameraScale;
    if (scale > this.maxCameraScale) scale = this.maxCameraScale;
    return scale;
  }

  updateCamera(camera) {
    if (this.cameraModel) {
      this.camera = camera;
      let { position: pos, v, up } = camera;
      let scale = this.calcScale();

      this.cameraModel.update({
        position: new GeoPoint(...pos),
        quaternionRotate: convertToQuaternion(
          { x: v[0], y: v[1], z: v[2] },
          { x: up[0], y: up[1], z: up[2] },
          true
        ),
        scale: scale,
        labelOffset: new GeoPoint(0, Math.max(2, (scale - 0.2) * 3)),
        label: {
          size: 0.8 + Math.max(0, (scale - 0.2) * 3),
        },
      });
    }
  }

  updateController(controller) {
    if (controller) {
      controller.forEach((c) => {
        let { handedness: hand, worldPosition: pos, worldUp: up, worldView: v } = c;

        window.hand = { up, v };

        if (hand === "left" && !this.controllerLeft) { this.createController(c); }
        else if (hand === "right" && !this.controllerRight) { this.createController(c); }
        else {
          const currentController = c.handedness === "left" ? this.controllerLeft : this.controllerRight;

          // 更新手把姿態
          currentController.model.update({
            position: new GeoPoint(pos),
            quaternionRotate: convertToQuaternion(
              { x: v.x, y: v.y, z: v.z },
              { x: up.x, y: up.y, z: up.z }
            ),
          });

          // 更新手把射線
          currentController.centerPoint = new GeoPoint(pos);
          currentController.TargetPoint = getTranslatedPointByVector(currentController.centerPoint, v, currentController.lineLength);
          currentController.line.update({ geo: new GeoPolyline([currentController.centerPoint, currentController.TargetPoint]) })

          // 數據記錄
          currentController.up = up;
          currentController.v = v;
        }
      });
    }
  }

  updateDeviceType(type) {
    if (type) {
      this.deviceType = type;
      this.cameraModel.update({ label: { text: `${this.deviceIcon[this.deviceType]} ${this.name}` } });
    }
  }

  deleteModel() {
    console.log(`Layer(${this.modelLayer.name}) has been removed`);
    terrainview.removeLayer(this.modelLayer);
  }

  goto() {
    this.cameraModel.goto();
  }
}

class Room {
  constructor(name, id) {
    if (!name || !id) {
      throw new Error("Both name and id are required");
    }
    this.name = name;
    this.id = id;
    this.guests = new Map();
    this.roomUrl = `${window.location.href}?room=${this.id}`;
  }

  addGuest(guest) {
    this.guests.set(guest.name, guest);
  }

  removeGuest(name) {
    this.guests.get(name).deleteModel();
    this.guests.delete(name);
  }
}

/** Utils */

/**
 * 回傳根據給定的座標點往指定方向移動指定距離後的新座標點
 * @param {ov.GeoPoint} point 給定的座標點
 * @param {ov.GeoPoint} vector 指定的方向
 * @param {Number} distance 移動的距離(公尺)
 * @returns {ov.GeoPoint} 新的座標點
 */
function getTranslatedPointByVector(point, vector, distance) {
  let newPoint = point.Clone();
  window.GetEPSGEngine().Transfer(4326, 3857, newPoint);
  newPoint.x += distance * vector.x;
  newPoint.y += distance * vector.y;
  newPoint.z += distance * vector.z;
  window.GetEPSGEngine().Transfer(3857, 4326, newPoint);
  return newPoint;
}

/**
 * 計算兩點之間的地理距離。
 * @param {Object} p1 - 第一個點的座標 {x, y, z}。
 * @param {Object} p2 - 第二個點的座標 {x, y, z}。
 * @param {number} [epsg=4326] (option) - 座標系統的 EPSG 編碼。預設為 4326,即 WGS84 座標系統。
 * @returns {number} 兩點之間的地理距離,單位為公尺。
 */
function calcPointGeoDistance(p1, p2, epsg = 4326) {
  const { x: lon1, y: lat1, z: alt1 } = p1;
  const { x: lon2, y: lat2, z: alt2 } = p2;

  const phi1 = lat1 * (Math.PI / 180);
  const phi2 = lat2 * (Math.PI / 180);
  const lambda1 = lon1 * (Math.PI / 180);
  const lambda2 = lon2 * (Math.PI / 180);

  const R = 6371e3; // 地球半徑,單位為公尺
  const theta = Math.acos(Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(lambda2 - lambda1));
  const distance = R * theta;

  const verticalDistance = Math.abs(alt1 - alt2);

  const totalDistance = Math.sqrt(distance ** 2 + verticalDistance ** 2);

  return totalDistance;
}

// 顯示 FPS
function showFPS() {
  const fps = new ov.Widget.FPS({ view: terrainview, style: { "width": "100px", "height": "30px", "position": "absolute", "top": "1080px" } })
  window.fps = fps
}

// 檢查字串是否為 JSON 格式
function isJSON(str) {
  try {
    return JSON.parse(str) && !!str;
  } catch (e) {
    return false;
  }
}

// 提示框
async function saPrompt(title, defaultValue) {
  const value = await Swal.fire({
    title,
    input: "text",
    inputValue: defaultValue || "",
    showCancelButton: true,
    inputValidator: (value) => {
      if (!value) {
        return "無效的輸入";
      }
    },
  });

  if (value) {
    return value;
  }
}

/**
 * 回傳一個隨機的名稱,格式範例 "{group}-{number}"  ex: Alpha-1, Beta-2, Charlie-3
 * @returns {string}
 */
function getRandomUserName() {
  const group = ["Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliett", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu"];
  const groupName = group[Math.floor(Math.random() * group.length)];
  const groupNumber = new Date().getTime().toString().substr(-3);
  return `${groupName}-${groupNumber}`;
}

/**
 * 提示視窗 (SweetAlert2)
 * @param {*} title
 * @param {*} icon "info" | "warning" | "error" | "success" | "question" ,預設為 "info"
 * @param {*} text
 */
function saAlert(title, icon = "info", text) {
  Swal.fire({
    title,
    text,
    icon,
    confirmButtonText: "OK",
  });
}

/**
 * 建立提示訊息實體, 詳見 https://sweetalert2.github.io/#toast
 */
const Toast = Swal.mixin({
  toast: true,
  position: "top-end",
  showConfirmButton: false,
  timer: 3000,
  timerProgressBar: true,
  didOpen: (toast) => {
    toast.addEventListener("mouseenter", Swal.stopTimer);
    toast.addEventListener("mouseleave", Swal.resumeTimer);
  },
});

/**
 * 處理滑鼠點擊事件
 * @param {*} args 事件參數
 */
function handleMouseDown(...args) {
  if (!createSymbolStatus) return; // 檢查圖標功能狀態
  const ClickResultWorldPos = args[2]; // 取得滑鼠點擊的世界座標

  if (args[0] == 0) { // 檢查是否為左鍵點擊
    createSymbol(ClickResultWorldPos); // 建立圖標
    createSymbolStatus = false; // 關閉圖標功能
  }
}

/**
 * 處理觸控結束事件
 * @param {*} e 事件對象
 */
function handleTouchEnd(e) {
  if (!createSymbolStatus) return;

  const pageX = e.changedTouches[e.changedTouches.length - 1].pageX; // 取得點擊的頁面座標 x
  const pageY = e.changedTouches[e.changedTouches.length - 1].pageY; // 取得點擊的頁面座標 y
  let pos = new GeoPoint(pageX, pageY); // 使用頁面座標建立 GeoPoint 物件
  terrainview.windowToTerrain(pos); // 將頁面座標轉換成世界座標
  let geo3dPos = new Geo3DPoint(pos.x, pos.y, 0); // 建立 3D 的 GeoPoint 物件

  createSymbol(geo3dPos); // 建立圖標
  createSymbolStatus = false; // 關閉圖標功能
}

/**
 * 處理鍵盤按鍵事件
 * @param {*} e 事件對象
 */
function handleKeyUp(e) {
  if (e.key === "c") {
    var g = meetRoom.guests.get("November-287");
    var scale = g.cameraModel._entity._Scale;
    var gp = meetRoom.guests.get("November-287").cameraModel._entity._OriginalGeo;
    var distance = calcPointGeoDistance(terrainview.camera.pos, gp);
    var size = g.getCameraScreenSize();
    console.log(`scale: ${scale}, distance: ${distance}, size: ${size}`);
  }
  if (e.key === "x") {
    if (isMenuExist == false) { initUI() } else switchUIDisplay();
  }
}

/**
 * VR 初始化成功後的回呼函式
 */
function initVRSuccess() {
  console.log("VR success!");
  isVRMode = true;
  userDevice = "vr";
  if (ws && ws.readyState === 1 && meetRoom) sendMyUserDeviceType();
  terrainview._TerrainEngine.XRHelper._MaxHeight = 200000;

  terrainview.addXRGamepadEventListener({
    handedness: "left",
    buttonIndex: 4,
    event: "onPressedDown",
    callback: () => {
      console.log("onPressedDown");
      if (isMenuExist == false) { initUI() } else switchUIDisplay();
    },
  });

  terrainview.addXRGamepadEventListener({
    handedness: "right",
    buttonIndex: 4,
    event: "onPressedDown",
    callback: () => {
      console.log("on Righthand A Button PressedDown");      
    },
  });

  initUI();
}

CameraQuatenion.js

用來計算會議室中的其他訪客的頭盔及控制器模型的旋轉四元數。

[關於模型]

由於不同模型會有不同的座標系及正面朝向,建議使用相同的模型,以確保正確的旋轉。若想變更模型,請確保模型的正面朝向及原點與範例中的模型一致。提供模型下載連結如下:

頭盔模型控制器模型

const zUpMatrix = new Matrix4(); // 創建一個矩陣對象,用於將向量轉換為z軸向上的坐標系
zUpMatrix.elements[0] = -1; // 矩陣元素,用於將x軸反向
zUpMatrix.elements[1] = 0; // 矩陣元素,用於將y軸保持不變
zUpMatrix.elements[2] = 0; // 矩陣元素,用於將z軸保持不變
zUpMatrix.elements[3] = 0; // 矩陣元素,用於將坐標原點保持不變
zUpMatrix.elements[4] = 0; // 矩陣元素,用於將x軸保持不變
zUpMatrix.elements[5] = 0; // 矩陣元素,用於將y軸保持不變
zUpMatrix.elements[6] = 1; // 矩陣元素,用於將z軸反向
zUpMatrix.elements[7] = 0; // 矩陣元素,用於將坐標原點保持不變
zUpMatrix.elements[8] = 0; // 矩陣元素,用於將x軸保持不變
zUpMatrix.elements[9] = -1; // 矩陣元素,用於將y軸反向
zUpMatrix.elements[10] = 0; // 矩陣元素,用於將z軸保持不變
zUpMatrix.elements[11] = 0; // 矩陣元素,用於將坐標原點保持不變
zUpMatrix.elements[12] = 0; // 矩陣元素,用於將x軸保持不變
zUpMatrix.elements[13] = 0; // 矩陣元素,用於將y軸保持不變
zUpMatrix.elements[14] = 0; // 矩陣元素,用於將z軸保持不變
zUpMatrix.elements[15] = 1; // 矩陣元素,用於將坐標原點保持不變

/**
 * 將向量轉換為四元數。
 * @param {Object} v - 要轉換的向量,包含 x、y 和 z 屬性。
 * @param {Object} up - 上方向向量,包含 x、y 和 z 屬性。
 * @param {boolean} isHelmet - 是否為頭盔模式。
 * @returns {Object} - 四元數物件,包含 x、y、z 和 w 屬性。
 */
const convertToQuaternion = (v, up, isHelmet) => {  
  const myMatrix = zUpMatrix; // 使用上面定義的矩陣對象

  const adjustV = myMatrix.multiplyVector3(new Vector3([v.x, v.y, v.z])); // 將v向量應用矩陣變換
  const adjustUp = myMatrix.multiplyVector3(new Vector3([up.x, up.y, up.z])); // 將up向量應用矩陣變換

  const rotate = new Matrix4(); // 創建一個矩陣對象,用於旋轉

  rotate.setLookAt(
      0, 0, 0,
      adjustV.elements[0], adjustV.elements[1], adjustV.elements[2],
      -adjustUp.elements[0], -adjustUp.elements[1], -adjustUp.elements[2]
  ); // 設置旋轉矩陣的參數

  const e = rotate.elements; // 獲取旋轉矩陣的元素

  const [
      m00, m01, m02, m03,
      m10, m11, m12, m13,
      m20, m21, m22, m23,
      m30, m31, m32, m33
  ] = e; // 解構賦值,將旋轉矩陣的元素賦值給變量

  let result;

  const tr = m00 + m11 + m22;
  if (tr > 0) {      
    const S = Math.sqrt(tr + 1.0) * 2; // S=4*qw
    const qw = 0.25 * S;
    const qx = (m21 - m12) / S;
    const qy = (m02 - m20) / S;
    const qz = (m10 - m01) / S;      
    result = { x: qx, y: qy, z: qz, w: qw };
  } else if ((m00 > m11) & (m00 > m22)) {      
    const S = Math.sqrt(1.0 + m00 - m11 - m22) * 2; // S=4*qx
    const qw = (m21 - m12) / S;
    const qx = 0.25 * S;
    const qy = (m01 + m10) / S;
    const qz = (m02 + m20) / S;      
    result = { x: qx, y: qy, z: qz, w: qw };
  } else if (m11 > m22) {      
    const S = Math.sqrt(1.0 + m11 - m00 - m22) * 2; // S=4*qy
    const qw = (m02 - m20) / S;
    const qx = (m01 + m10) / S;
    const qy = 0.25 * S;
    const qz = (m12 + m21) / S;      
    result = { x: qx, y: qy, z: qz, w: qw };
  } else {      
    const S = Math.sqrt(1.0 + m22 - m00 - m11) * 2; // S=4*qz
    const qw = (m10 - m01) / S;
    const qx = (m02 + m20) / S;
    const qy = (m12 + m21) / S;
    const qz = 0.25 * S;      
    result = { x: qx, y: qy, z: qz, w: qw };
  }

  const angle = -(Math.PI / 2); // 設定90度並轉為弧度
  const correctionQuaternion = {
    w: Math.cos(angle / 2),
    x: 0,
    y: Math.sin(angle / 2),
    z: 0
  };

  // 手把轉向 y 軸 180 度
  const controllerAngle180 = Math.PI;
  const controllerCorrectionQuaternion180 = {
    w: Math.cos(controllerAngle180 / 2),
    x: 0,
    y: Math.sin(controllerAngle180 / 2),
    z: 0
  };

  const resultQuaternion = multiplyQuaternions(result, isHelmet ? correctionQuaternion : controllerCorrectionQuaternion180);

  if (isHelmet){
    return resultQuaternion;
  }else{      
    return resultQuaternion;     
  }
};

/**
 * 將向量轉換為四元數的原點函數
 * @param {Object} v - 要轉換的向量,包含 x、y、z 屬性
 * @param {Object} up - 上方向向量,包含 x、y、z 屬性
 * @returns {Object} - 四元數結果,包含 x、y、z、w 屬性
 */
const convertToQuaternionOrigin = (v, up) => {
  const adjustV = myMatrix.multiplyVector3(new Vector3([v.x, v.y, v.z])); // 將v向量應用矩陣變換
  const adjustUp = myMatrix.multiplyVector3(new Vector3([up.x, up.y, up.z])); // 將up向量應用矩陣變換

  const rotate = new Matrix4(); // 創建一個矩陣對象,用於旋轉

  rotate.setLookAt(
      0, 0, 0,
      adjustV.elements[0], adjustV.elements[1], adjustV.elements[2],
      -adjustUp.elements[0], -adjustUp.elements[1], -adjustUp.elements[2]                
  ); // 設置旋轉矩陣的參數
  const e = rotate.elements; // 獲取旋轉矩陣的元素

  const [
      m00, m01, m02, m03,
      m10, m11, m12, m13,
      m20, m21, m22, m23,
      m30, m31, m32, m33
  ] = e; // 解構賦值,將旋轉矩陣的元素賦值給變量

  let result;

  const tr = m00 + m11 + m22;
  if (tr > 0) {    
    const S = Math.sqrt(tr + 1.0) * 2; // S=4*qw
    const qw = 0.25 * S;
    const qx = (m21 - m12) / S;
    const qy = (m02 - m20) / S;
    const qz = (m10 - m01) / S;    
    result = { x: qx, y: qy, z: qz, w: qw };
  } else if ((m00 > m11) & (m00 > m22)) {    
    const S = Math.sqrt(1.0 + m00 - m11 - m22) * 2; // S=4*qx
    const qw = (m21 - m12) / S;
    const qx = 0.25 * S;
    const qy = (m01 + m10) / S;
    const qz = (m02 + m20) / S;    
    result = { x: qx, y: qy, z: qz, w: qw };
  } else if (m11 > m22) {    
    const S = Math.sqrt(1.0 + m11 - m00 - m22) * 2; // S=4*qy
    const qw = (m02 - m20) / S;
    const qx = (m01 + m10) / S;
    const qy = 0.25 * S;
    const qz = (m12 + m21) / S;    
    result = { x: qx, y: qy, z: qz, w: qw };
  } else {    
    const S = Math.sqrt(1.0 + m22 - m00 - m11) * 2; // S=4*qz
    const qw = (m10 - m01) / S;
    const qx = (m02 + m20) / S;
    const qy = (m12 + m21) / S;
    const qz = 0.25 * S;
    result = { x: qx, y: qy, z: qz, w: qw };
  }

  return result;  
};

/**
 * 用於計算兩個四元數的乘法。
 *
 * @param {Object} q1 - 第一個四元數。
 * @param {Object} q2 - 第二個四元數。
 * @returns {Object} - 返回計算結果的四元數。
 */
function multiplyQuaternions(q1, q2) {
    const a1 = q1.w; // 第一個四元數的w分量
    const b1 = q1.x; // 第一個四元數的x分量
    const c1 = q1.y; // 第一個四元數的y分量
    const d1 = q1.z; // 第一個四元數的z分量
    const a2 = q2.w; // 第二個四元數的w分量
    const b2 = q2.x; // 第二個四元數的x分量
    const c2 = q2.y; // 第二個四元數的y分量
    const d2 = q2.z; // 第二個四元數的z分量

    const w = a1 * a2 - b1 * b2 - c1 * c2 - d1 * d2; // 計算結果的w分量
    const x = a1 * b2 + b1 * a2 + c1 * d2 - d1 * c2; // 計算結果的x分量
    const y = a1 * c2 - b1 * d2 + c1 * a2 + d1 * b2; // 計算結果的y分量
    const z = a1 * d2 + b1 * c2 - c1 * b2 + d1 * a2; // 計算結果的z分量

    return { w: w, x: x, y: y, z: z }; // 返回計算結果的四元數
}

menuHandle.js

用來處理 VR 模式下的使用者選單。

// 全域變數
let modelset = null;
let isMenuExist = false;
let i3skh = null;
let acute3d = null;
let dddtilesTaipei = null;

// ICON 變數
const menuSvgSrc = new URL("./src/menu/menu_white_48dp.svg", location.href).href;
const accountSvgSrc = new URL("./src/menu/account_circle_white_48dp.svg", location.href).href;
const closeSvgSrc = new URL("./src/menu/close_white_48dp.svg", location.href).href;
const logoutSvgSrc = new URL("./src/menu/logout_white_48dp.svg", location.href).href;
const layersSvgSrc = new URL("./src/menu/layers_white_48dp.svg", location.href).href;
const placeSvgSrc = new URL("./src/menu/place_white_48dp.svg", location.href).href;
const addSvgSrc = new URL("./src/menu/add_circle_white_48dp.svg", location.href).href;
const visibleOnSvgSrc = new URL("./src/menu/visibility_white_48dp.svg", location.href).href;
const visibleOffSvgSrc = new URL("./src/menu/visibility_white_off_48dp.svg", location.href).href;
const deleteSvgSrc = new URL("./src/menu/delete_outline_white_48dp.svg", location.href).href;

// 基本距離
const baseDistance = 10;

// 使用者介面管理器
const menuManager = { buttons: [], layers: {} };
window.menuManager = menuManager;

/**
 * 建立圖層選項
 * @param {number} iconSize - 圖示尺寸
 * @param {string} text - 圖層名稱
 * @param {number} textWidth - 文字寬度
 * @param {number} textHeight - 文字高度
 * @param {()=>void} [callback] - 點擊回呼函式
 * @returns {BlockEntity} - 圖層選項容器
 */
const createLayerContainer = (iconSize, text, textWidth, textHeight, callback) => {
    menuManager.layers[text] = {};

    // 圖層選項容器
    const container = terrainview.createVRUIBlockEntity({
        color: new window.ov.Color("black"),
        distance: baseDistance,
        align: "start",
        onHoverColor: new window.ov.Color("5577ff"),
        onSelectColor: new window.ov.Color("fff243"),
        // onClick: callback
    });
    menuManager.layers[text].layerContainer = container;

    // Icon Button - 前往
    const btnGoto = terrainview.createVRUIBlockEntity({
        color: new window.ov.Color("gray"),
        width: iconSize,
        height: iconSize,
        imgSrc: placeSvgSrc,
        distance: baseDistance - 0.001,
        onHoverColor: new window.ov.Color("5577ff"),
        onSelectColor: new window.ov.Color("fff243"),
        onClick: () => {
            const layer = getLayer(text);
            if (layer) XRGoto(layer);
        }
    });
    menuManager.layers[text].btnGoto = btnGoto;

    // Icon Button - 載入
    const btnLoad = terrainview.createVRUIBlockEntity({
        color: new window.ov.Color("gray"),
        width: iconSize,
        height: iconSize,
        imgSrc: addSvgSrc,
        distance: baseDistance - 0.001,
        onHoverColor: new window.ov.Color("5577ff"),
        onSelectColor: new window.ov.Color("fff243"),
        onClick: callback
    });
    menuManager.layers[text].btnLoad = btnLoad;

    // Icon Button - 切換可見性
    const btnSwitchVisible = terrainview.createVRUIBlockEntity({
        color: new window.ov.Color("gray"),
        width: iconSize,
        height: iconSize,
        imgSrc: visibleOffSvgSrc,
        distance: baseDistance - 0.001,
        onHoverColor: new window.ov.Color("5577ff"),
        onSelectColor: new window.ov.Color("fff243"),
        onClick: () => {
            const layer = getLayer(text);
            const currentVisible = layer.show;

            if (currentVisible !== undefined) {
                layer.show = !currentVisible;
                if ('setDrawEdge' in layer) layer.setDrawEdge(!currentVisible);
            }

            btnSwitchVisible.update({ imgSrc: currentVisible ? visibleOffSvgSrc : visibleOnSvgSrc });
        }
    });
    menuManager.layers[text].btnSwitchVisible = btnSwitchVisible;

    // Icon Button - 移除
    const btnRemove = terrainview.createVRUIBlockEntity({
        color: new window.ov.Color("gray"),
        width: iconSize,
        height: iconSize,
        imgSrc: deleteSvgSrc,
        distance: baseDistance - 0.001,
        onHoverColor: new window.ov.Color("5577ff"),
        onSelectColor: new window.ov.Color("fff243"),
        onClick: () => {
            const layer = getLayer(text);
            if (layer) terrainview.removeLayer(layer);
        }
    });
    menuManager.layers[text].btnRemove = btnRemove;

    // Icon Button - 移除
    const gap = terrainview.createVRUIBlockEntity({
        color: new window.ov.Color("black"),
        width: 0.1,
        distance: baseDistance - 0.001,
    });
    menuManager.layers[text].gap = gap;

    // Label - 圖層名稱
    const lblLayerName = terrainview.createVRUITextEntity({
        align: "left",
        width: textWidth,
        height: textHeight,
        distance: baseDistance - 0.001,
        onHoverDistance: baseDistance - 0.05,
        onSelectDistance: baseDistance - 0.05,
        label: {
            text: ' ' + text + ' ',
            fontSize: 50,
        }
    });
    menuManager.layers[text].lblLayerName = lblLayerName;

    container.addInline(gap);
    container.addInline(lblLayerName);
    container.addInline(btnLoad);
    container.addInline(btnRemove);
    container.addInline(btnSwitchVisible);
    container.addInline(btnGoto);
    return container;
};

/**
 * 建立按鈕
 * @param {string} icon - 圖示路徑
 * @param {number} iconSize - 圖示尺寸
 * @param {()=>void} [callback] - 點擊回呼函式
 * @returns {Button} - 按鈕
 */
const createButton = (icon, iconSize, callback) => {
    const button = terrainview.createVRUIBlockEntity({
        color: new window.ov.Color("gray"),
        onHoverColor: new window.ov.Color("5577ff"),
        onSelectColor: new window.ov.Color("fff243"),
        width: iconSize,
        height: iconSize,
        imgSrc: icon,
        distance: baseDistance - 0.001,
        onClick: callback
    });
    menuManager.buttons.push(button);
    return button;
};

/**
 * 切換 ModelSet 圖層的顯示
 */
const switchModelSet = () => {
    if (!modelset) {
        terrainview.addModelSetLayer({
            url: "https://samplecode.pilotgaea.com.tw/src/PGWebJS/13.0/oviewRP.ashx",
            identifier: "shp_Zhubei",
            callback: function (success, layer) {
                if (layer) {
                    modelset = layer;
                    modelset.setAllowHoverEntity(true);
                    modelset.setDrawEdge(true);

                    if (isVRMode) {
                        XRGoto(modelset);
                    } else {
                        modelset.goto();
                    }

                } else {
                    console.log("gml載入不成功");
                }
            }
        });
    } else {
        terrainview.removeLayer(modelset);
        modelset = null;
    }
};

/**
 * 切換 i3s_Kaohsiung 圖層的顯示
 */
const switchI3SKH = () => {
    if (!i3skh) {
        terrainview.addOGCI3SLayer({
            url: "https://i3s.nlsc.gov.tw/building/i3s/SceneServer/layers/4",
            callback: (success, layer) => {
                console.log(layer, success);
                window.i3s = layer;
                i3skh = layer;

                if (isVRMode) {
                    XRGoto(i3skh);
                } else {
                    i3skh.goto();
                }
            }
        });
    } else {
        i3skh.show = !i3skh.show;
    }
};

/**
 * 切換 3DTilesTaipei 圖層的顯示
 */
const switch3DTilesTaipei = () => {
    if (!i3skh) {
        terrainview.addOGC3DTilesLayer({
            url: "https://3dtiles.nlsc.gov.tw/building/tiles3d/0/tileset.json",
            callback: (success, layer) => {
                console.log(layer, success);
                window.ddd = layer;
                dddtilesTaipei = layer;

                if (isVRMode) {
                    XRGoto(dddtilesTaipei);
                } else {
                    dddtilesTaipei.goto();
                }
            }
        });
    } else {
        i3skh.show = !i3skh.show;
    }
};

/**
 * 切換傾斜攝影圖層的顯示
 */
const switchAcute3d = () => {
    if (!acute3d) {
        terrainview.addPhotogrammetryModelLayer(
            {
                url: "https://samplecode.pilotgaea.com.tw/src/PGWebJS/13.0/oviewRP.ashx",
                identifier: "範例傾斜攝影模型圖",
                callback: function (success, layer) {
                    if (success) {
                        //設定變數給圖層後,goto()直接前往圖層位置
                        acute3d = layer;

                        if (isVRMode) {
                            XRGoto(acute3d);
                        } else {
                            acute3d.goto();
                        }
                    }
                    else {
                        console.log("傾斜攝影不成功");
                    }
                }
            }
        );
    } else {
        acute3d.show = !acute3d.show;
    }
}

/**
 * 初始化使用者介面
 */
const initUI = () => {
    isMenuExist = true;

    const offsetX = 1.0;
    const offsetY = -1.5;
    const size = 0.5;

    //小選單容器
    const collapseMenuContainer = terrainview.createVRUIBlockEntity({
        position: new window.GeoPoint(-2.5 + offsetX, 3 + offsetY),
        color: new window.ov.Color("black"),
        distance: baseDistance,
    });
    menuManager.collapseMenuContainer = collapseMenuContainer;

    //大選單容器
    const expandMenuContainer = terrainview.createVRUIBlockEntity({
        position: new window.GeoPoint(-2.2 + offsetX, 3 + offsetY),
        color: new window.ov.Color("black"),
        distance: baseDistance,
    });
    expandMenuContainer.show = false;
    menuManager.expandMenuContainer = expandMenuContainer;

    //圖層容器
    const layerContainer = terrainview.createVRUIBlockEntity({
        position: new window.GeoPoint(-1.2, 1),
        color: new window.ov.Color(0, 0, 0, 0),
        distance: baseDistance + 0.001,
    });
    layerContainer.show = false;
    window.lc = layerContainer;
    console.log(lc);
    menuManager.layerListContainer = layerContainer;

    //#region 小選單
    const menuButton = createButton(menuSvgSrc, size, () => {
        collapseMenuContainer.show = false;
        expandMenuContainer.show = true;
    });
    collapseMenuContainer.add(menuButton);
    menuManager.menuButton = menuButton;
    //#endregion

    //#region 大選單
    const closeButton = createButton(closeSvgSrc, size, () => {
        collapseMenuContainer.show = true;
        expandMenuContainer.show = false;
        layerContainer.show = false;
    });
    const accountButton = createButton(accountSvgSrc, size);
    const layerButton = createButton(layersSvgSrc, size, () => {
        layerContainer.show = !layerContainer.show;
    });
    const logoutButton = createButton(logoutSvgSrc, size);
    expandMenuContainer.add(closeButton);
    // expandMenuContainer.addInline(accountButton);
    expandMenuContainer.addInline(layerButton);
    // expandMenuContainer.addInline(logoutButton);
    //#endregion
    menuManager.closeButton = closeButton;
    menuManager.accountButton = accountButton;
    menuManager.layerButton = layerButton;
    menuManager.logoutButton = logoutButton;

    //#region 圖層選單
    layerContainer.add(createLayerContainer(size, "shp_Zhubei", 3, size, switchModelSet));
    layerContainer.add(createLayerContainer(size, "i3s_Kaohsiung", 3, size, switchI3SKH));
    layerContainer.add(createLayerContainer(size, "acute3d_TaiChung", 3, size, switchAcute3d));
    layerContainer.add(createLayerContainer(size, "3DTiles_Taipei", 3, size, switch3DTilesTaipei));
    //#endregion 圖層選單

    //#region FPS
    const fpsContainer = terrainview.createVRUIBlockEntity({
        position: new window.GeoPoint(-1.75 + offsetX, 3.8 + offsetY),
        color: new window.ov.Color("black"),
        distance: baseDistance,
    });
    menuManager.fpsContainer = fpsContainer;

    const fpsText = terrainview.createVRUITextEntity({
        width: 2,
        height: 0.5,
        distance: baseDistance - 0.001,
        label: {
            text: `FPS: ${terrainview.getFPS()}`,
            fontSize: 50,
        }
    });
    window.fps = fpsContainer;
    fpsContainer.add(fpsText);
    menuManager.fpsText = fpsText;

    setInterval(() => {
        fpsText.update({
            label: {
                text: `FPS: ${terrainview.getFPS()}`,
            }
        });
    }, 500);
    //#endregion

    terrainview.addVRUIBlockEntity(collapseMenuContainer);
    terrainview.addVRUIBlockEntity(expandMenuContainer);
    terrainview.addVRUIBlockEntity(layerContainer);
    terrainview.addVRUIBlockEntity(fpsContainer);
};

/**
 * 切換使用者介面的顯示
 */
const switchUIDisplay = () => {
    terrainview._TerrainEngine.XRHelper.UIManager._Blocks.forEach(block => {
        block.SetShow(!block.IsShow())
    });
};

/**
 * 前往指定圖層
 * @param {Layer} layer - 圖層
 */
const XRGoto = (layer) => {
    const geo3dSphere = layer._layer.GetBoundingSphere();
    if (geo3dSphere) {
        const pt = new Geo3DPoint(geo3dSphere.Center);
        pt.z = (pt.z + (geo3dSphere.Radius / 2));
        layer._TerrainView.setXRPosition(pt);
    }
}

/**
 * 取得指定名稱的圖層
 * @param {string} layerName - 圖層名稱
 * @returns {Layer} - 圖層
 */
const getLayer = (layerName) => {
    const layers = {
        "shp_Zhubei": modelset,
        "i3s_Kaohsiung": i3skh,
        "acute3d_TaiChung": acute3d,
        "3DTiles_Taipei": dddtilesTaipei
    }
    return layers[layerName];
}

用來處理 VR 模式下的使用者選單。

style.css

用來設定網頁模式下的使用者選單的樣式。

html,
body,
#bodyContainer {
    height: 100vh;
    width: 100vw;
    margin: 0;
    padding: 0;
    background-color: #fff;
    color: #000;
    font-family: Arial, Helvetica, sans-serif;
    font-size: 12px;
    line-height: 1.5em;
    overflow: hidden;
}

#MapContainer {
    height: 100%;
    width: 100%;
    margin: 0;
    padding: 0;
    overflow: hidden;
}

.EntityTooltip {
    color: white !important;
    text-shadow: red 0px 0px 6px !important;
    transform: translateX(-65%);
}

#ToolContainer {
    width: 120px;

    position: absolute;
    z-index: 10;
}

.btnFunc {
    width: 100px;
    height: 30px;
    background-color: royalblue;
    border: 1px solid white;
    color: white;
    border-radius: 5px;
    cursor: pointer;
    margin-left: 5px;
    text-align: center;
    margin-bottom: 2px;
}

div.btnFunc{
    box-sizing: border-box;
    display:flex;
    justify-content:center;
    align-items:center;
}

#userName {
    border: 1px solid white;
    border-radius: 5px;
    margin: 5px;
    padding: 5px;
    color: white;
    background-color: royalblue;
    width: fit-content;    
}
#userName.named {
    border: 1px solid royalblue;
    color: royalblue;
    background-color: white;
    width: fit-content;    
}

#vr {
    width: 100px;
    height: 40px;
    background-color: royalblue;
    border: 1px solid white;
    color: white;
    border-radius: 5px;
    cursor: pointer;
    margin-left: 5px;
    margin-bottom: 2px;
    -webkit-user-select: none;
    -moz-user-select: none;
    -o-user-select: none;
    user-select: none;
}

 /* #vr attr: disable */

#vr[disabled] {
    background-color: #ccc;
    cursor: not-allowed; 
}
Copyright © NCHC 2022 Version:13.0 all right reserved,powered by Gitbook修訂時間: 2024-11-20 13:52:09