initial commit

This commit is contained in:
JouChun 2026-06-11 13:47:00 -04:00
commit 3a74a4cda9
62 changed files with 4043 additions and 0 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
.vscode
node_modules

2
.env Normal file
View File

@ -0,0 +1,2 @@
APS_CLIENT_ID = 'ulPmu8QxBzlIgSGqJODqdi47OEZHGpra0263lsk9aVvmZ6YH'
APS_CLIENT_SECRET = '3FvoT0y8roixHoGDrDjNfvnphPAli1neRrJlbJYZoch4BA9Lr6F4n519MC3FNbCO'

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.vscode
node_modules
.DS_Store
**/.vscode
**/node_modules

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# check=skip=SecretsUsedInArgOrEnv
# ===== Build Vue =====
FROM node:22 AS frontend-builder
WORKDIR /frontend
COPY wwwroot/ibms_ems/package*.json ./
RUN npm install
COPY wwwroot/ibms_ems ./
RUN npm run build
# ===== Backend =====
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
# copy Vue build
COPY --from=frontend-builder /frontend/dist ./wwwroot/ibms_ems/dist
ENV NODE_ENV=production \
PORT=8080 \
APS_CLIENT_ID= \
APS_CLIENT_SECRET=
EXPOSE 8080
CMD ["npm", "run", "start"]

7
Dockerfile.dev Normal file
View File

@ -0,0 +1,7 @@
FROM node:22
WORKDIR /app
# 安裝 backend deps
COPY package*.json ./
RUN npm install

6
README.md Normal file
View File

@ -0,0 +1,6 @@
`npm i` 下載包
`cd ./wwwroot/ibms_ems`
`npm i` 下載包
利用 `docker compose -f docker-compose.dev.yml up` 啟動整個專案

16
config.js Normal file
View File

@ -0,0 +1,16 @@
require('dotenv').config();
let { APS_CLIENT_ID, APS_CLIENT_SECRET, APS_BUCKET, PORT } = process.env;
if (!APS_CLIENT_ID || !APS_CLIENT_SECRET) {
console.warn('Missing some of the environment variables.');
process.exit(1);
}
APS_BUCKET = APS_BUCKET || `${APS_CLIENT_ID.toLowerCase()}-basic-app`;
PORT = PORT || 8080;
module.exports = {
APS_CLIENT_ID,
APS_CLIENT_SECRET,
APS_BUCKET,
PORT
};

25
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,25 @@
version: "3.9"
services:
backend:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8080:8080"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
- PORT=8080
command: sh -c "npm run start"
frontend:
image: node:22
working_dir: /app
ports:
- "5173:5173"
volumes:
- ./wwwroot/ibms_ems:/app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"

12
docker-compose.yml Normal file
View File

@ -0,0 +1,12 @@
version: "3.9"
services:
app:
build: .
ports:
- "8080:8080"
environment:
- NODE_ENV=production
- PORT=8080
- APS_CLIENT_ID=${APS_CLIENT_ID}
- APS_CLIENT_SECRET=${APS_CLIENT_SECRET}

1336
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "ibms_ems",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@aps_sdk/authentication": "^1.0.1",
"@aps_sdk/model-derivative": "^1.2.1",
"@aps_sdk/oss": "^1.3.3",
"dotenv": "^17.4.1",
"express": "^5.2.1",
"express-formidable": "^1.2.0"
}
}

14
routes/auth.js Normal file
View File

@ -0,0 +1,14 @@
const express = require('express');
const { getViewerToken } = require('../services/aps.js');
let router = express.Router();
router.get('/api/auth/token', async function (req, res, next) {
try {
res.json(await getViewerToken());
} catch (err) {
next(err);
}
});
module.exports = router;

39
server.js Normal file
View File

@ -0,0 +1,39 @@
const express = require('express');
const path = require('path');
const { PORT } = require('./config.js');
const app = express();
if (process.env.NODE_ENV === 'production') {
// 🔧 URL rewrite 必須在所有路由之前執行(對應 Vite dev proxy 的行為)
app.use((req, res, next) => {
if (req.path.startsWith('/forge/api')) {
req.url = req.url.replace(/^\/forge\/api/, '/api');
req.path = req.path.replace(/^\/forge\/api/, '/api');
}
next();
});
const distPath = path.join(__dirname, 'wwwroot/ibms_ems/dist');
app.use(express.static(distPath));
}
// 👉 API routes在 rewrite 之後)
app.use(require('./routes/auth.js'));
if (process.env.NODE_ENV === 'production') {
// Vue Router history mode (Express 5 catch-all syntax)
app.get('/{*path}', (req, res) => {
res.sendFile(path.join(__dirname, 'wwwroot/ibms_ems/dist', 'index.html'));
});
} else {
// 👉 Dev只跑 API不管前端
app.get('/', (req, res) => {
res.send('API server running...');
});
}
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}...`);
});

25
services/aps.js Normal file
View File

@ -0,0 +1,25 @@
const { AuthenticationClient, Scopes } = require('@aps_sdk/authentication');
const { OssClient, Region, PolicyKey } = require('@aps_sdk/oss');
const { ModelDerivativeClient, View, OutputType } = require('@aps_sdk/model-derivative');
const { APS_CLIENT_ID, APS_CLIENT_SECRET, APS_BUCKET } = require('../config.js');
const authenticationClient = new AuthenticationClient();
const ossClient = new OssClient();
const modelDerivativeClient = new ModelDerivativeClient();
const service = module.exports = {};
async function getInternalToken() {
const credentials = await authenticationClient.getTwoLeggedToken(APS_CLIENT_ID, APS_CLIENT_SECRET, [
Scopes.DataRead,
Scopes.DataCreate,
Scopes.DataWrite,
Scopes.BucketCreate,
Scopes.BucketRead
]);
return credentials.access_token;
}
service.getViewerToken = async () => {
return await authenticationClient.getTwoLeggedToken(APS_CLIENT_ID, APS_CLIENT_SECRET, [Scopes.ViewablesRead]);
};

24
wwwroot/ibms_ems/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ibms_ems</title>
</head>
<body>
<div id="app"></div>
<script src="https://developer.api.autodesk.com/modelderivative/v2/viewers/7.*/viewer3D.js"></script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1430
wwwroot/ibms_ems/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"name": "ibms_ems",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"@tweenjs/tween.js": "^25.0.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"three": "^0.183.2",
"vue": "^3.5.32"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,189 @@
<script setup>
import background from "@/assets/bg_tech.jpg";
import titleLogo from "@/assets/title.png";
import ForgeViewer from "@/components/ForgeViewer.vue";
import Info from "@/components/Info.vue";
import { ref, useTemplateRef, watch } from "vue";
import btn01B from "@/assets/btn_01_b.png";
import btn01R from "@/assets/btn_01_r.png";
import btn02B from "@/assets/btn_02_b.png";
import btn02R from "@/assets/btn_02_r.png";
import btn03B from "@/assets/btn_03_b.png";
import btn03R from "@/assets/btn_03_r.png";
import btn04B from "@/assets/btn_04_b.png";
import btn04R from "@/assets/btn_04_r.png";
import btn05B from "@/assets/btn_05_b.png";
import btn05R from "@/assets/btn_05_r.png";
import btn06B from "@/assets/btn_06_b.png";
import btn06R from "@/assets/btn_06_r.png";
import btn07B from "@/assets/btn_07_b.png";
import btn07R from "@/assets/btn_07_r.png";
import btn08B from "@/assets/btn_08_b.png";
import btn08R from "@/assets/btn_08_r.png";
import line01 from "@/assets/line_01.png";
import line02 from "@/assets/line_02.png";
import line03 from "@/assets/line_03.png";
import line04 from "@/assets/line_04.png";
import line05 from "@/assets/line_05.png";
import line06 from "@/assets/line_06.png";
import line07 from "@/assets/line_07.png";
import line08 from "@/assets/line_08.png";
import changeCameraPosition from "@/utils/changeCameraPosition.js";
import changeCameraView from "@/utils/changeCameraView.js";
import ForgeLabel from "@/components/ForgeLabel.vue";
const left_data = ref([
{
title: "POWER DEMAND",
normalImg: btn01B,
hoverImg: btn01R,
forgeID: 72,
line: line01,
tag: "TAG_Electricity_meter",
arcSide: -1,
cameraDistance: 5,
value: 13.25,
content: [
"Power Demand: 12.8 MW",
"Capacity Usage: 56%",
"Contract Capacity: 22.8 MW",
"Energy Consumption Today: 86,500 kWh",
"Monthly Peak Demand: 18.6 MW",
"30-Min Demand Forecast: 16.2 MW"
]
},
{
title: "CARBON REDUCTION",
normalImg: btn02B,
hoverImg: btn02R,
forgeID: 181,
line: line02,
tag: "",
arcSide: -1,
cameraDistance: 5,
value: 1280
},
{
title: "ENVIRONMENT INDEX",
normalImg: btn03B,
hoverImg: btn03R,
forgeID: 30,
line: line03,
tag: "",
arcSide: -1,
cameraDistance: 5,
value: 856
},
{
title: "PUE",
normalImg: btn04B,
hoverImg: btn04R,
forgeID: 41,
line: line04,
tag: "ROOM_Information_Data_Center",
arcSide: 1,
cameraDistance: 5,
value: 1.22
},
]);
const right_data = ref([
{
title: "AI PREDICTED YIELD",
normalImg: btn05B,
hoverImg: btn05R,
forgeID: 51,
line: line05,
tag: "ROOM_OFFICE",
arcSide: 1,
cameraDistance: 2,
value: 92
},
{
title: "REAL-TIME THROUGHPUT",
normalImg: btn06B,
hoverImg: btn06R,
forgeID: 61,
line: line06,
tag: "TAG_Chiller-Water_Cooled",
arcSide: -1,
cameraDistance: 10,
value: 96
},
{
title: "REAL-TIME",
normalImg: btn07B,
hoverImg: btn07R,
forgeID: 71,
line: line07,
tag: "TAG_solder_paste_screen_printer",
arcSide: -1,
cameraDistance: 5,
value: 98
},
{
title: "SAFETY & ALARMS",
normalImg: btn08B,
hoverImg: btn08R,
forgeID: 81,
line: line08,
tag: "",
arcSide: -1,
cameraDistance: 5,
value: 1.5
},
]);
const forgeViewerRef = useTemplateRef("forgeViewerRef");
const forgeLabelRef = useTemplateRef("forgeLabelRef");
const imgSrcActive = ref(null);
const onClick = (item) => {
if (item.tag === "") return
imgSrcActive.value = item;
//TODO:
console.log(forgeLabelRef.value);
changeCameraPosition(
forgeViewerRef.value.forgeViewer,
forgeLabelRef.value.tagDom,
item,
);
// console.log(forgeViewerRef.value.Viewpoints[7]);
// changeCameraView(forgeViewerRef.value.forgeViewer, forgeViewerRef.value.Viewpoints[7].data);
};
watch(
() => forgeViewerRef.value?.tagData,
(tagList) => {
if (!tagList || tagList.length === 0) return;
left_data.value = left_data.value.map((item) => {
const tagInfo = tagList.find((d) => d.tag === item.tag);
return tagInfo ? { ...item, ...tagInfo } : item;
});
right_data.value = right_data.value.map((item) => {
const tagInfo = tagList.find((d) => d.tag === item.tag);
return tagInfo ? { ...item, ...tagInfo } : item;
});
},
{ immediate: true, deep: true },
);
</script>
<template>
<div class="w-screen h-screen overflow-hidden relative flex items-center justify-center bg-cover"
:style="{ backgroundImage: `url('${background}')` }">
<img :src="titleLogo" alt="Title Logo" class="absolute top-0 left-0 m-4 w-full h-[106px]" />
<Info position="left" :data="left_data" :imgSrcActive="imgSrcActive?.forgeID" :onClick="onClick" />
<ForgeViewer ref="forgeViewerRef" :forge-ids="forgeIDs" />
<Info position="right" :data="right_data" :imgSrcActive="imgSrcActive?.forgeID" :onClick="onClick" />
<ForgeLabel ref="forgeLabelRef" v-if="imgSrcActive" :data="imgSrcActive" />
</div>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -0,0 +1,98 @@
<template>
<div id="tag" ref="tag" class="hud-container z-50 hidden">
<div class="hud-panel p-5 text-white text-3xl flex justify-center items-center">
<span class="tag_name mr-5">
{{ data.title }}
</span>
<div class="flex flex-col items-start">
<span v-for="(value, index) in data.content" :key="index" class="text-xl">
{{ value }}
</span>
</div>
</div>
<div class="hud-connector">
<div class="hud-connector-line"></div>
<div class="hud-connector-dot"></div>
</div>
</div>
</template>
<script setup>
import { ref, defineExpose, defineProps } from 'vue';
const props = defineProps({
data: {
type: Object,
required: true
}
});
const tag = ref(null);
defineExpose({
tagDom: tag
})
</script>
<style scoped>
:root {
--hud-cyan: #35f5ff;
--hud-cyan-soft: rgba(53, 245, 255, 0.4);
--hud-bg: #1b1f26;
--hud-panel-bg: #2a303a;
}
.hud-container {
flex-direction: column;
align-items: center;
gap: 8px;
}
.hud-panel {
position: relative;
background: radial-gradient(circle at top left, #3b4656 0%, #1b1f26 55%, #11151b 100%);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow:
0 0 0 1px rgba(53, 245, 255, 0.15),
0 0 18px rgba(53, 245, 255, 0.35),
inset 0 0 12px rgba(0, 0, 0, 0.7);
overflow: hidden;
}
.hud-panel::before {
content: "";
position: absolute;
inset: 4px;
border-radius: 8px;
border: 1px solid rgba(53, 245, 255, 0.35);
box-shadow: 0 0 12px rgba(53, 245, 255, 0.4);
opacity: 0.9;
}
.hud-connector {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.hud-connector-line {
width: 2px;
height: 18px;
background: linear-gradient(to bottom, rgba(53, 245, 255, 0.7), rgba(53, 245, 255, 0));
box-shadow: 0 0 6px rgba(53, 245, 255, 0.8);
}
.hud-connector-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: radial-gradient(circle, #ffffff 0%, #35f5ff 40%, #0b1518 100%);
box-shadow:
0 0 8px rgba(53, 245, 255, 0.9),
0 0 16px rgba(53, 245, 255, 0.7);
}
</style>

View File

@ -0,0 +1,202 @@
<template>
<Loader v-if="loading" />
<div class="w-[1106px] h-[966px] absolute z-10 top-20 bg-cover overflow-hidden"
:style="{ backgroundImage: `url('${CircleBg}')` }">
<div id="preview" ref="forgeViewerDOM" class="forge-ellipse translate-y-[15px] w-[1106px] h-[966px]"></div>
</div>
<!-- <button class="absolute z-50 top-5 left-5 bg-white" @click.prevent="onClick">切換視角</button>
<div class="w-screen h-screen ">
<div id="preview" ref="forgeViewerDOM"></div>
</div> -->
</template>
<script setup>
import { defineProps, ref, toRaw, watch } from "vue";
import changeCameraPosition from "@/utils/changeCameraPosition.js";
import CircleBg from "@/assets/light_circle.png";
import getTag from "@/utils/getTag.js";
import Loader from "./Loader.vue";
const MODEL_URN = 'dXJuOmFkc2sub2JqZWN0czpvcy5vYmplY3Q6dWxwbXU4cXhiemxpZ3NncWpvZHFkaTQ3b2V6aGdwcmEwMjYzbHNrOWF2dm16NnloLWRlbW8vJUUzJTgwJTkwUFJPM0NfU21hcnRfRmFjdG9yeSVFMyU4MCU5MTA2MDIubndk'
const props = defineProps({
forgeIds: {
type: Object,
required: true
}
})
const forgeViewerDOM = ref(null);
const forgeViewer = ref(null);
const tagData = ref([]);
const loading = ref(true);
const getAccessToken = async (callback) => {
try {
const resp = await fetch('/forge/api/auth/token');
if (!resp.ok) {
throw new Error(await resp.text());
}
const { access_token, expires_in } = await resp.json();
callback(access_token, expires_in);
} catch (err) {
alert('Could not obtain access token. See the console for more details.');
console.error(err);
}
}
const initViewer = (container) => {
return new Promise(function (resolve, reject) {
Autodesk.Viewing.Initializer({ env: 'AutodeskProduction', getAccessToken }, function () {
const config = {
extensions: ['Autodesk.DocumentBrowser']
};
const viewer = new Autodesk.Viewing.GuiViewer3D(container, config);
Autodesk.Viewing.Private.InitParametersSetting.alpha = true;
viewer.start();
resolve(viewer);
});
});
}
function findViewpoints(node, result = []) {
if (!node) return result;
const data = node.data;
// view / viewpoint
// console.log('Node:', data, data.type, data.role, data.camera, data.name, data.isViewpoint);
if (
data &&
(data.type === 'view' && data.role === '3d')
) {
result.push(node);
}
if (node.children) {
node.children.forEach(child => findViewpoints(child, result));
}
return result;
}
const Viewpoints = ref([]);
const loadModel = (viewer, urn) => {
return new Promise(function (resolve, reject) {
function onDocumentLoadSuccess(doc) {
viewer.setGroundShadow(false);
viewer.impl.renderer().setClearAlpha(0); //clear alpha channel
viewer.impl.glrenderer().setClearColor(0xffffff, 0); //set transparent background, color code does not matter
viewer.impl.invalidate(true); //trigger rendering
const root = doc.getRoot();
const views = findViewpoints(root);
console.log('Viewpoints:', views);
Viewpoints.value = views;
resolve(viewer.loadDocumentNode(doc, doc.getRoot().getDefaultGeometry()));
}
function onDocumentLoadFailure(code, message, errors) {
reject({ code, message, errors });
}
viewer.setLightPreset(0);
Autodesk.Viewing.Document.load('urn:' + urn, onDocumentLoadSuccess, onDocumentLoadFailure);
});
}
//
const initialCameraPosition = ref(null);
const initialCameraTarget = ref(null);
watch(forgeViewerDOM, async (newVal) => {
if (newVal) {
try {
const viewer = await initViewer(newVal);
forgeViewer.value = toRaw(viewer);
await loadModel(viewer, MODEL_URN);
//
viewer.addEventListener(Autodesk.Viewing.GEOMETRY_LOADED_EVENT, async () => {
// viewer.fitToView();
// target
const nav = viewer.navigation;
const cameraPos = nav.getPosition().clone();
const cameraTarget = nav.getTarget().clone();
initialCameraPosition.value = cameraPos
initialCameraTarget.value = cameraTarget;
// UI調
const scaleFactor = 1.3; // 調
const direction = cameraPos.clone().sub(cameraTarget).normalize();
const distance = cameraPos.distanceTo(cameraTarget) * scaleFactor;
const newPos = cameraTarget.clone().add(direction.multiplyScalar(distance));
viewer.navigation.setView(newPos, cameraTarget); //
const tags = await getTag(viewer);
tagData.value = tags;
console.log('Tags:', tags);
loading.value = false;
});
} catch (err) {
alert('Failed to load Forge Viewer. See the console for more details.');
console.error(err);
}
}
})
const onClick = () => {
if (forgeViewer.value) {
changeCameraPosition(forgeViewer.value);
}
}
defineExpose({
forgeViewer,
initialCameraPosition,
initialCameraTarget,
tagData,
Viewpoints
})
</script>
<style scoped>
.forge-ellipse {
/* 橢圓裁切 */
clip-path: circle(483px at 50% 50%);
}
.adsk-viewing-viewer,
.adsk-viewing-canvas,
.adsk-viewing-overlay,
.adsk-viewing-overlay div {
background-color: transparent !important;
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<div :class="twMerge(
'flex flex-col items-center justify-center h-full absolute -bottom-10 z-30 ',
position === 'left' ? 'left-5' : 'right-5'
)">
<template v-for="(d, i) in data" :key="d.title">
<div class="my-6 relative" @click.prevent="() => onClick(d)">
<div class="relative">
<img :src="imgSrcActive === d.forgeID ? d.hoverImg : d.normalImg" alt="Image" class="w-[360px] h-[150px]">
<img :src="d.line" alt="Line" :class="twMerge(
'absolute ',
position === 'left' ? 'left-full' : 'right-full',
i % 4 === 3 ? 'top-0' : 'top-1/2'
)">
</div>
<p class="absolute bottom-1/4 translate-y-1/2 right-1/5 mb-1 font-bold text-white text-5xl">{{ d.value }}</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, defineProps } from 'vue';
import { twMerge } from 'tailwind-merge'
const props = defineProps({
position: {
type: String,
required: true,
validator: value => ['left', 'right'].includes(value)
},
data: {
type: Array,
required: true
},
imgSrcActive: {
type: [String, Number],
default: null
},
onClick: {
type: Function,
required: true
}
})
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,22 @@
<template>
<img :src="btn" alt="Image" class="w-[360px] h-[150px]">
</template>
<script setup>
import InfoCardCircle from './InfoCardCircle.vue'
import btn from '@/assets/btn_01_b.png'
const props = defineProps({
value: [Number, String],
})
</script>
<style scoped>
.card {
display: flex;
align-items: center;
background: #0b1020;
color: #fff;
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<div class="w-full h-full absolute z-40 top-0 left-0 bg-black opacity-50 flex items-center justify-center ">
</div>
<div class="loader text-red absolute z-50 "></div>
</template>
<script setup>
</script>
<style lang="css" scoped>
/* HTML: <div class="loader"></div> */
.loader {
width: 150px;
height: 15px;
--c:#90f4f5 50%,#0000 0;
background:
linear-gradient( 90deg,var(--c)) 0 0,
linear-gradient(-90deg,var(--c)) 0 0;
background-size: 20px 100%;
background-repeat: repeat-x;
animation: l10 1s infinite linear;
}
@keyframes l10 {
100% {background-position: -20px 0,20px 0}
}
</style>

View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

View File

@ -0,0 +1,5 @@
@import "tailwindcss";
@theme {
--color-btech: #121063;
}

View File

@ -0,0 +1,237 @@
import * as TWEEN from "@tweenjs/tween.js";
import { toRaw } from "vue";
const THREE = window.THREE;
let rafId = null;
/**
* 相機沿弧線飛行
*/
function flyArc(param) {
const {
viewer,
fromPos,
fromTarget,
toPos,
toTarget,
label,
returnToOriginal = false,
duration = 3000,
item,
arcSide = -1,
} = param;
const nav = viewer.navigation;
// 中間控制點(弧線)
const distance = fromPos.distanceTo(toPos);
const forward = toPos.clone().sub(fromPos).normalize();
const up = new THREE.Vector3(0, 1, 0);
const side = new THREE.Vector3().crossVectors(forward, up).normalize();
const midPos = fromPos.clone().lerp(toPos, 0.5);
// ⭐ 控制左右方向
midPos.add(side.multiplyScalar(distance * arcSide * 0.7));
const curve = new THREE.QuadraticBezierCurve3(fromPos, midPos, toPos);
const tweenObj = { t: 0 };
const tween = new TWEEN.Tween(tweenObj)
.to({ t: 1 }, duration)
.easing(TWEEN.Easing.Cubic.InOut)
.onStart(() => {
if (returnToOriginal) {
label.classList.remove("flex");
label.classList.add("hidden");
}
})
.onUpdate(() => {
const point = curve.getPoint(tweenObj.t);
nav.setPosition(point);
const target = fromTarget.clone().lerp(toTarget, tweenObj.t);
nav.setTarget(target);
})
.onComplete(() => {
console.log("飛行完成", label.classList, returnToOriginal);
if (!returnToOriginal) {
label.querySelector(".tag_name").textContent = item.title;
label.classList.remove("hidden");
label.classList.add("flex");
}
})
.start();
function animate(time) {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
rafId = requestAnimationFrame(animate);
tween.update(time);
}
animate();
}
/**
* 取得物件 BoundingBox
*/
function getObjectBoundingBox(viewer, dbId) {
const THREE = window.THREE;
const model = viewer.model;
const tree = model.getInstanceTree();
const fragList = model.getFragmentList();
const bbox = new THREE.Box3();
tree.enumNodeFragments(
dbId,
(fragId) => {
const fragBox = new THREE.Box3();
fragList.getWorldBounds(fragId, fragBox);
bbox.union(fragBox);
},
true,
);
return bbox;
}
/**
* 計算相機最佳距離物件越大相機越遠
*/
function computeCameraOffset(viewer, bbox, distanceMultiplier = 7) {
const THREE = window.THREE;
const nav = viewer.navigation;
const center = bbox.getCenter(new THREE.Vector3());
const size = bbox.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
// FOV
const fov = (nav.getVerticalFov() * Math.PI) / 180;
let distance = maxDim / (2 * Math.tan(fov / 2));
// 拉遠一點
distance *= distanceMultiplier;
// ⭐ 固定斜上角方向(超重要)
const direction = new THREE.Vector3(-0.35, -0.5, 1).normalize();
// ⭐ 相機位置
const position = center.clone().add(direction.multiplyScalar(distance));
return {
position,
target: center,
};
}
function showByName(v, id) {
v.hideAll();
v.show(id);
}
// 取得樓層
function isolateFloor(viewer, minZ, maxZ) {
const model = viewer.model;
const tree = model.getInstanceTree();
const fragList = model.getFragmentList();
const THREE = window.THREE;
const visibleDbIds = [];
tree.enumNodeChildren(
tree.getRootId(),
(dbId) => {
if (tree.getChildCount(dbId) !== 0) return;
const bbox = new THREE.Box3();
tree.enumNodeFragments(dbId, (fragId) => {
const fragBox = new THREE.Box3();
fragList.getWorldBounds(fragId, fragBox);
bbox.union(fragBox);
});
const centerZ = bbox.getCenter(new THREE.Vector3()).z;
if (centerZ >= minZ && centerZ <= maxZ) {
visibleDbIds.push(dbId);
}
},
true,
);
viewer.hideAll();
viewer.setGhosting(false); // ⭐ 關鍵
viewer.isolate(visibleDbIds);
}
/**
* 主功能飛到物件 再飛回原點
*/
export default function changeCameraPosition(forgeViewer, label, item) {
const viewer = toRaw(forgeViewer);
console.log("切換視角到物件:", item);
showByName(viewer, item.parent);
// isolateFloor(viewer, -3000, 0); // 單位看你的模型
const nav = viewer.navigation;
// ⭐ 記錄相機初始位置與 target
const initialPos = nav.getPosition().clone();
const initialTarget = nav.getTarget().clone();
// ⭐ 取得物件資訊
const bbox = getObjectBoundingBox(viewer, item.forgeID);
const center = bbox.getCenter(new THREE.Vector3());
console.log("物件中心:", center);
// ⭐ 計算最佳觀察點
const { position, target } = computeCameraOffset(viewer, bbox, item.cameraDistance);
// ⭐ 第 1 段:飛到物件
const toTarget = {
viewer,
fromPos: initialPos,
fromTarget: initialTarget,
toPos: position,
toTarget: target,
label,
returnToOriginal: false,
duration: 5000,
item,
arcSide: item.arcSide,
};
flyArc(toTarget);
// ⭐ 第 2 段:停 0.5 秒後飛回原點
setTimeout(() => {
const toOriginal = {
viewer,
fromPos: position,
fromTarget: target,
toPos: initialPos,
toTarget: initialTarget,
label,
returnToOriginal: true,
duration: 5000,
item,
arcSide: -(item.arcSide),
};
flyArc(toOriginal);
viewer.showAll(); // 恢復顯示所有物件
}, 8000);
}

View File

@ -0,0 +1,64 @@
import * as TWEEN from "@tweenjs/tween.js";
const THREE = window.THREE;
let currentTween = null;
function flyArc(viewer, cameraArr, duration = 3000) {
// 🛑 停掉前一個動畫
if (currentTween) {
TWEEN.remove(currentTween);
}
const startPos = viewer.navigation.getPosition().clone();
const startTarget = viewer.navigation.getTarget().clone();
const endPos = new THREE.Vector3(cameraArr[0], cameraArr[1], cameraArr[2]);
const endTarget = new THREE.Vector3(cameraArr[3], cameraArr[4], cameraArr[5]);
const distance = startPos.distanceTo(endPos);
// 🎯 建立「控制點」(讓路徑變弧形)
const mid = startPos.clone().lerp(endPos, 0.5);
mid.z += distance * 1.5; // 弧度高度
// 👉 建立 Bezier 曲線
const curve = new THREE.QuadraticBezierCurve3(startPos, mid, endPos);
const obj = { t: 0 };
const tween = new TWEEN.Tween(obj)
.to({ t: 1 }, duration)
.easing(TWEEN.Easing.Cubic.InOut)
.onUpdate(() => {
const t = obj.t;
// 🎯 沿曲線取點
const pos = curve.getPoint(t);
// 👉 target 用線性插值(穩定)
const target = startTarget.clone().lerp(endTarget, t);
viewer.navigation.setView(pos, target);
})
.onComplete(() => {
currentTween = null;
})
.start();
currentTween = tween;
// 🧠 確保 Tween 有在跑
function animate(time) {
if (currentTween) {
requestAnimationFrame(animate);
currentTween.update(time);
}
}
animate();
}
export default function changeCameraView(forgeViewer, Viewpoint) {
flyArc(forgeViewer, Viewpoint.camera);
}

View File

@ -0,0 +1,50 @@
export default async function getTag(viewer) {
const tree = viewer.model.getInstanceTree();
const result = [];
const tasks = [];
tree.enumNodeChildren(
tree.getRootId(),
(dbId) => {
tasks.push(
new Promise((resolve) => {
viewer.getProperties(dbId, (props) => {
const tag = props.properties.find((p) =>
p.displayName.includes("tag_id"),
);
if (tag) {
let currentId = dbId;
let parentId = tree.getNodeParentId(currentId);
while (parentId !== tree.getRootId()) {
currentId = parentId;
parentId = tree.getNodeParentId(currentId);
}
console.log("最上層父節點 ID:", currentId);
result.push({
forgeID: dbId,
tag: tag.displayValue,
parent: currentId,
});
}
resolve();
});
}),
);
},
true,
);
await Promise.all(tasks);
const uniqueResult = [];
result.forEach((item) => {
if (!uniqueResult.some((i) => i.tag === item.tag)) {
uniqueResult.push(item);
}
});
return uniqueResult;
}

View File

@ -0,0 +1,8 @@
module.exports = {
content: [
"./src/**/*.{vue,js}"
],
theme: {
},
plugins: []
}

View File

@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath, URL } from "node:url";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
"@ASSET": fileURLToPath(new URL("./src/assets", import.meta.url)),
},
},
server: {
host: true, // 🔥 關鍵
port: 5173,
proxy: {
"/forge": {
target: process.env.NODE_ENV === "production" ? "http://backend:8080" : "http://localhost:8080", // Backend server
changeOrigin: true, // Ensure the request appears to come from the frontend server
rewrite: (path) => path.replace(/^\/forge/, ""), // Optional: Remove '/api' prefix
},
},
},
});