Hi.Sample.Webapi/wwwroot/demo-plain-inline.html
2025-07-23 15:21:17 +08:00

766 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>UJoin HiNC Demo</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background-color: #f0f0f0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
width: 95%;
height: 95vh;
max-width: 1400px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
background-color: #2196F3;
color: white;
padding: 20px;
text-align: center;
}
.status {
background-color: #e8f5e9;
padding: 10px;
margin: 10px;
border-radius: 4px;
font-size: 14px;
}
.status.connected {
background-color: #c8e6c9;
color: #2e7d32;
}
.status.error {
background-color: #ffcdd2;
color: #c62828;
}
.controls {
padding: 20px;
background-color: #f5f5f5;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.controls button {
padding: 10px 20px;
border: none;
border-radius: 4px;
background-color: #2196F3;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.controls button:hover {
background-color: #1976D2;
}
.controls button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.view-controls {
padding: 10px 20px;
background-color: #e3f2fd;
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.view-controls button {
padding: 8px 16px;
border: 1px solid #2196F3;
border-radius: 4px;
background-color: white;
color: #2196F3;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
}
.view-controls button:hover {
background-color: #2196F3;
color: white;
}
.canvas-container {
position: relative;
width: 100%;
flex: 1;
min-height: 400px;
background-color: #f8f8f8;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
box-sizing: border-box;
overflow: hidden; /* 防止滾動條 */
}
#renderCanvas {
width: calc(100% - 20px); /* 減去 padding */
height: calc(100% - 20px); /* 減去 padding */
display: block;
cursor: grab;
border: 2px solid #666;
outline: none;
transition: border-color 0.3s, box-shadow 0.3s;
box-sizing: border-box;
background-color: white; /* 確保有背景色 */
}
#renderCanvas:focus {
border-color: #4CAF50;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
}
#renderCanvas:active {
cursor: grabbing;
}
.log-container {
padding: 20px;
background-color: #f5f5f5;
height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
flex-shrink: 0;
}
.log-entry {
margin: 2px 0;
padding: 2px 5px;
}
.log-entry.info {
color: #1976D2;
}
.log-entry.error {
color: #c62828;
background-color: #ffebee;
}
.log-entry.warning {
color: #f57c00;
}
.log-entry.debug {
color: #666;
font-style: italic;
}
</style>
</head>
<body>
<div class="container">
<h1>HiNC WebAPI 渲染畫布示例</h1>
<div style="padding: 10px; background-color: #fff3cd; color: #856404; border: 1px solid #ffeaa7; border-radius: 4px; margin: 10px;">
<strong>提示:</strong>點擊畫布以啟用鍵盤控制。畫布獲得焦點時邊框會變成綠色。
<br><strong>鍵盤控制:</strong>F1-F4切換視圖方向鍵旋轉視圖PageUp/PageDown縮放Home重置視圖
<br><strong>觸控手勢:</strong>單指拖動平移,雙指捏合縮放,雙指旋轉,三指上下滑動縮放
</div>
<div class="view-controls">
<button onclick="setView('front')">前視圖</button>
<button onclick="setView('back')">後視圖</button>
<button onclick="setView('left')">左視圖</button>
<button onclick="setView('right')">右視圖</button>
<button onclick="setView('top')">頂視圖</button>
<button onclick="setView('bottom')">底視圖</button>
<button onclick="setView('isometric')">等角視圖</button>
<button onclick="setView('home')">主視圖</button>
</div>
<div class="canvas-container">
<canvas id="renderCanvas" tabindex="0"></canvas>
</div>
<div class="log-container" id="logContainer"></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js"></script>
<script>
let connection = null;
let sessionId = null;
let canvas = null;
let canvasInitialized = false;
let compressionSupported = false;
let isFirefox = false;
// 頁面加載時自動連接
window.addEventListener('load', async () => {
canvas = document.getElementById('renderCanvas');
compressionSupported = typeof DecompressionStream !== 'undefined';
isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
console.log('頁面加載完成,開始連接服務器...');
await connect();
});
// 頁面卸載時斷開連接
window.addEventListener('beforeunload', () => {
if (connection && connection.state === signalR.HubConnectionState.Connected) {
connection.stop();
}
});
// 處理頁面可見性變化
document.addEventListener('visibilitychange', async () => {
console.log(`頁面可見性變化: ${document.visibilityState}`);
if (connection && connection.state === signalR.HubConnectionState.Connected) {
try {
await connection.invoke("HandleVisibilityChange", document.visibilityState);
console.log(`已通知服務器可見性狀態: ${document.visibilityState}`);
} catch (err) {
console.error("處理可見性變化失敗:", err);
}
}
});
async function connect() {
try {
connection = new signalR.HubConnectionBuilder()
.withUrl("/renderingHub")
.configureLogging(signalR.LogLevel.Information)
.build();
// 設置消息處理器
connection.on("ImageUpdate", handleImageUpdate);
connection.on("CanvasInitialized", (id) => {
sessionId = id;
canvasInitialized = true;
console.log("畫布初始化完成會話ID:", sessionId);
// 自動載入測試物件
loadTestObjects();
});
// 連接到服務器
await connection.start();
console.log("已連接到服務器");
// 初始化畫布
const rect = canvas.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
console.log(`初始化畫布 - 寬度: ${width}, 高度: ${height}`);
// 設置畫布的實際像素大小
canvas.width = width;
canvas.height = height;
if (width <= 0 || height <= 0) {
console.error("畫布大小無效,延遲初始化...");
setTimeout(async () => {
const newRect = canvas.getBoundingClientRect();
const newWidth = Math.floor(newRect.width);
const newHeight = Math.floor(newRect.height);
console.log(`重新嘗試初始化畫布 - 寬度: ${newWidth}, 高度: ${newHeight}`);
canvas.width = newWidth;
canvas.height = newHeight;
await connection.invoke("InitializeCanvas", newWidth, newHeight);
}, 100);
return;
}
await connection.invoke("InitializeCanvas", width, height);
// 設置事件處理
setupMouseEvents();
setupKeyboardEvents();
setupTouchEvents();
// 監聽窗口大小變化
window.addEventListener('resize', handleWindowResize);
} catch (err) {
console.error("連接失敗:", err);
setTimeout(() => connect(), 5000); // 5秒後重試
}
}
// 斷開連接
async function disconnect() {
console.log('===== 開始斷開連接 =====');
if (connection) {
try {
console.log('正在停止 SignalR 連接...');
await connection.stop();
console.log('✓ SignalR 連接已停止');
updateStatus('未連接', false);
sessionId = null;
console.log('清除 Canvas...');
ctx.clearRect(0, 0, canvas.width, canvas.height);
console.log('✓ Canvas 已清除');
window.frameCount = 0;
console.log('===== 斷開連接完成 =====');
} catch (err) {
console.error('斷開連接失敗: ' + err);
}
} else {
console.warn('沒有活動的連接');
}
}
// 載入測試對象
async function loadTestObjects() {
console.log('開始載入測試對象');
if (!sessionId) {
console.warn('尚未連接到服務器,無法載入測試對象');
return;
}
try {
const url = `/api/rendering/test-objects/${sessionId}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
console.log('測試對象載入成功');
} else {
const errorText = await response.text();
console.error(`載入測試對象失敗: ${response.status} - ${errorText}`);
}
} catch (err) {
console.error('載入測試對象時出錯:', err);
}
}
// 設置視圖
async function setView(viewType) {
console.log(`===== 切換視圖: ${viewType} =====`);
if (connection && connection.state === 'Connected') {
try {
console.log(`正在調用 SetView('${viewType}')...`);
await connection.invoke('SetView', viewType);
console.log(`✓ 視圖已成功切換到: ${viewType}`);
} catch (err) {
console.error(`切換視圖失敗: ${err}`);
console.error('錯誤詳情: ' + JSON.stringify(err));
}
} else {
console.warn(`無法切換視圖: 連接狀態為 ${connection ? connection.state : 'null'}`);
}
}
// 渲染幀
function renderFrame(rgba, width, height) {
try {
// 檢查參數
if (!rgba || !width || !height) {
console.error(`無效的渲染參數 - rgba: ${rgba ? 'exists' : 'null'}, width: ${width}, height: ${height}`);
return;
}
// 檢查數據長度
const expectedLength = width * height * 4;
if (rgba.length !== expectedLength) {
console.error(`數據長度不匹配 - 預期: ${expectedLength}, 實際: ${rgba.length}`);
return;
}
// 確保 rgba 是正確的類型
let uint8Array;
if (rgba instanceof Uint8ClampedArray) {
uint8Array = rgba;
} else if (rgba instanceof Array || rgba instanceof Uint8Array) {
uint8Array = new Uint8ClampedArray(rgba);
} else {
console.error(`未知的數據類型: ${typeof rgba}`);
return;
}
const imageData = new ImageData(uint8Array, width, height);
ctx.putImageData(imageData, 0, 0);
// 不記錄每一幀,避免日誌過多
// log('info', '幀已渲染');
} catch (err) {
console.error('渲染錯誤: ' + err.message);
console.error('詳細錯誤:', err);
}
}
async function handleImageUpdate(compressedData, originalLength, width, height) {
try {
console.log(`收到圖像更新 - 寬度: ${width}, 高度: ${height}, 原始長度: ${originalLength}, 壓縮數據類型: ${typeof compressedData}`);
let imageData;
if (compressionSupported) {
// 解壓縮數據
const decompressed = await decompressData(compressedData, originalLength);
imageData = new Uint8ClampedArray(decompressed);
} else {
// 不支持壓縮,直接使用數據
imageData = new Uint8ClampedArray(compressedData);
}
console.log(`圖像數據準備就緒,長度: ${imageData.length}`);
// 創建 ImageData 對象並繪製到畫布
const ctx = canvas.getContext('2d');
const imgData = new ImageData(imageData, width, height);
ctx.putImageData(imgData, 0, 0);
console.log('圖像已繪製到畫布');
} catch (err) {
console.error("處理圖像更新時出錯:", err);
}
}
async function decompressData(compressedData, originalLength) {
try {
let bytes;
// 檢查輸入類型
if (compressedData instanceof Uint8Array) {
bytes = compressedData;
} else if (compressedData instanceof ArrayBuffer) {
bytes = new Uint8Array(compressedData);
} else if (typeof compressedData === 'string') {
// 如果是 Base64 字符串,轉換為 Uint8Array
const binaryString = atob(compressedData);
bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
} else {
console.error("未知的壓縮數據類型:", typeof compressedData);
throw new Error("未知的壓縮數據類型");
}
// 使用 DecompressionStream 解壓縮
const stream = new ReadableStream({
start(controller) {
controller.enqueue(bytes);
controller.close();
}
});
const decompressedStream = stream.pipeThrough(new DecompressionStream('gzip'));
const reader = decompressedStream.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
// 合併所有塊
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
} catch (err) {
console.error("解壓縮失敗:", err);
throw err;
}
}
function setupMouseEvents() {
let isMouseDown = false;
let lastX = 0;
let lastY = 0;
let mouseButton = 0;
canvas.addEventListener('mousedown', async (e) => {
if (!canvasInitialized) return;
isMouseDown = true;
mouseButton = e.button;
const rect = canvas.getBoundingClientRect();
lastX = e.clientX - rect.left;
lastY = e.clientY - rect.top;
try {
await connection.invoke("HandleMouseDown", lastX, lastY, e.button);
} catch (err) {
console.error("鼠標按下處理失敗:", err);
}
});
canvas.addEventListener('mousemove', async (e) => {
if (!canvasInitialized) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 計算 buttonMask
const buttonMask = isMouseDown ? (1 << mouseButton) : 0;
try {
await connection.invoke("HandleMouseMove", x, y, buttonMask);
} catch (err) {
console.error("鼠標移動處理失敗:", err);
}
lastX = x;
lastY = y;
});
canvas.addEventListener('mouseup', async (e) => {
if (!canvasInitialized) return;
isMouseDown = false;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
try {
await connection.invoke("HandleMouseUp", x, y, e.button);
} catch (err) {
console.error("鼠標釋放處理失敗:", err);
}
});
canvas.addEventListener('wheel', async (e) => {
if (!canvasInitialized) return;
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 根據瀏覽器類型調整滾輪縮放因子
const browserBrand = isFirefox ? 'firefox' : 'chrome';
try {
await connection.invoke("HandleMouseWheel", x, y, e.deltaX, e.deltaY, browserBrand);
} catch (err) {
console.error("鼠標滾輪處理失敗:", err);
}
});
// 防止右鍵菜單
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
}
function setupKeyboardEvents() {
// Canvas 焦點管理
canvas.addEventListener('click', () => {
console.log('Canvas clicked, focusing...');
canvas.focus();
});
canvas.addEventListener('focus', () => {
console.log('Canvas focused');
canvas.style.borderColor = '#28a745';
});
canvas.addEventListener('blur', () => {
console.log('Canvas lost focus');
canvas.style.borderColor = '#dee2e6';
});
// 鍵盤事件
canvas.addEventListener('keydown', async (e) => {
console.log(`鍵盤按下: key=${e.key}, code=${e.code}, ctrl=${e.ctrlKey}, shift=${e.shiftKey}, alt=${e.altKey}`);
if (!canvasInitialized) {
console.warn('Canvas 尚未初始化,忽略鍵盤事件');
return;
}
// 防止瀏覽器默認行為(如 F5 刷新頁面)
e.preventDefault();
try {
await connection.invoke("HandleKeyDown", e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey);
console.log('鍵盤按下事件已發送到服務器');
} catch (err) {
console.error("鍵盤按下處理失敗:", err);
}
});
canvas.addEventListener('keyup', async (e) => {
console.log(`鍵盤釋放: key=${e.key}, code=${e.code}`);
if (!canvasInitialized) {
console.warn('Canvas 尚未初始化,忽略鍵盤事件');
return;
}
e.preventDefault();
try {
await connection.invoke("HandleKeyUp", e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey);
console.log('鍵盤釋放事件已發送到服務器');
} catch (err) {
console.error("鍵盤釋放處理失敗:", err);
}
});
}
function setupTouchEvents() {
canvas.addEventListener('touchstart', async (e) => {
if (!canvasInitialized) return;
e.preventDefault();
// 處理所有觸控點,支援多點觸控
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
try {
await connection.invoke("HandleTouchDown", touch.identifier, x, y);
console.log(`觸控開始 [${touch.identifier}]: (${x}, ${y})`);
} catch (err) {
console.error("觸摸開始處理失敗:", err);
}
}
});
canvas.addEventListener('touchmove', async (e) => {
if (!canvasInitialized) return;
e.preventDefault();
// 處理所有移動的觸控點,支援捏合縮放和旋轉手勢
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
try {
await connection.invoke("HandleTouchMove", touch.identifier, x, y);
} catch (err) {
console.error("觸摸移動處理失敗:", err);
}
}
// 顯示當前觸控點數量(用於調試)
if (e.touches.length >= 2) {
console.log(`多點觸控中 - 觸控點數量: ${e.touches.length}`);
}
});
canvas.addEventListener('touchend', async (e) => {
if (!canvasInitialized) return;
e.preventDefault();
// 處理所有結束的觸控點
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
try {
await connection.invoke("HandleTouchUp", touch.identifier);
console.log(`觸控結束 [${touch.identifier}]`);
} catch (err) {
console.error("觸摸結束處理失敗:", err);
}
}
});
// 添加觸控取消事件處理
canvas.addEventListener('touchcancel', async (e) => {
if (!canvasInitialized) return;
e.preventDefault();
// 處理所有取消的觸控點
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
try {
await connection.invoke("HandleTouchUp", touch.identifier);
console.log(`觸控取消 [${touch.identifier}]`);
} catch (err) {
console.error("觸摸取消處理失敗:", err);
}
}
});
}
let resizeTimeout = null;
async function handleWindowResize() {
// 使用防抖來避免頻繁調整
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(async () => {
const rect = canvas.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
console.log(`調整畫布大小: ${width}x${height}`);
// 保存當前畫布內容
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 更新畫布大小
canvas.width = width;
canvas.height = height;
// 嘗試恢復畫布內容(如果大小相容)
try {
if (imageData.width > 0 && imageData.height > 0) {
ctx.putImageData(imageData, 0, 0);
}
} catch (e) {
console.warn('無法恢復畫布內容,等待服務器重繪');
}
if (connection && connection.state === signalR.HubConnectionState.Connected) {
try {
// 通知服務器調整大小並重新渲染
await connection.invoke("HandleResize", width, height);
console.log('已通知服務器調整大小');
} catch (err) {
console.error("調整大小失敗:", err);
}
}
}, 300); // 300ms 防抖延遲
}
</script>
</body>
</html>