This commit is contained in:
iambossTC 2025-07-18 20:20:21 +08:00
parent 37373ab176
commit 4b03058156
8 changed files with 1619 additions and 37 deletions

View File

@ -34,9 +34,6 @@ namespace Hi.Webapi.Hubs
var engine = _renderingService.GetOrCreateEngine(sessionId); var engine = _renderingService.GetOrCreateEngine(sessionId);
_logger.LogInformation($"Engine created/retrieved - SessionId: {sessionId}"); _logger.LogInformation($"Engine created/retrieved - SessionId: {sessionId}");
// 創建並設置測試顯示對象
_logger.LogInformation($"TestDisplayee set - SessionId: {sessionId}");
// 啟動引擎(必須在設置回調之前) // 啟動引擎(必須在設置回調之前)
engine.Start(width, height); engine.Start(width, height);
_logger.LogInformation($"Engine started with size {width}x{height} - SessionId: {sessionId}"); _logger.LogInformation($"Engine started with size {width}x{height} - SessionId: {sessionId}");
@ -50,7 +47,7 @@ namespace Hi.Webapi.Hubs
// 設置渲染回調 // 設置渲染回調
unsafe unsafe
{ {
engine.ImageRequestAfterBufferSwapped += (bgra, w, h) => engine.ImageRequestAfterBufferSwapped += (byte* bgra, int w, int h) =>
{ {
if (bgra == null) if (bgra == null)
return; return;

View File

@ -1,3 +1,7 @@
using Hi.Disp;
using Hi.Licenses;
using Hi.Webapi.Hubs;
using Hi.Webapi.Services;
namespace Hi.Webapi namespace Hi.Webapi
{ {
@ -5,44 +9,76 @@ namespace Hi.Webapi
{ {
public static void Main(string[] args) public static void Main(string[] args)
{ {
License.LogInAll();
DispEngine.Init();
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddAuthorization(); builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
// 添加 SignalR
builder.Services.AddSignalR(options =>
{
options.MaximumReceiveMessageSize = 10 * 1024 * 1024; // 10MB
});
// 添加 CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// 註冊 RenderingService
builder.Services.AddSingleton<RenderingService>();
// 配置允許 unsafe 代碼
builder.Services.Configure<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>(options =>
{
options.AllowSynchronousIO = true;
});
var app = builder.Build(); var app = builder.Build();
// Configure application lifetime events
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
DispEngine.FinishDisp();
License.LogOutAll();
Console.WriteLine($"App exit.");
});
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.MapOpenApi();
} }
app.UseHttpsRedirection(); // 在開發環境中不使用 HTTPS 重定向
// app.UseHttpsRedirection();
// 添加靜態文件支援
app.UseStaticFiles();
app.UseCors("AllowAll");
app.UseAuthorization(); app.UseAuthorization();
var summaries = new[] app.MapControllers();
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", (HttpContext httpContext) => // 映射 SignalR Hub
{ app.MapHub<RenderingHub>("/renderingHub");
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast // 添加一個簡單的首頁
{ app.MapGet("/", () => Results.Redirect("/demo-vue.html"));
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
})
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.Run(); app.Run();
} }

View File

@ -1,13 +0,0 @@
namespace Hi.Webapi
{
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
}

734
wwwroot/demo-native.html Normal file
View File

@ -0,0 +1,734 @@
<!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>鍵盤控制F1-F4切換視圖方向鍵旋轉視圖PageUp/PageDown縮放Home重置視圖
</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() {
let touchStartX = 0;
let touchStartY = 0;
canvas.addEventListener('touchstart', async (e) => {
if (!canvasInitialized) return;
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
touchStartX = touch.clientX - rect.left;
touchStartY = touch.clientY - rect.top;
try {
await connection.invoke("HandleTouchDown", touch.identifier, touchStartX, touchStartY);
} catch (err) {
console.error("觸摸開始處理失敗:", err);
}
});
canvas.addEventListener('touchmove', async (e) => {
if (!canvasInitialized) return;
e.preventDefault();
const touch = e.touches[0];
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);
}
});
canvas.addEventListener('touchend', async (e) => {
if (!canvasInitialized) return;
e.preventDefault();
const touch = e.changedTouches[0];
try {
await connection.invoke("HandleTouchUp", 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>

115
wwwroot/demo-vue.html Normal file
View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple Vue.js Rendering Canvas Demo</title>
<!-- Use vue.global.prod.js for production deployment -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@latest/dist/browser/signalr.min.js"></script>
<style>
body {
margin: 0;
padding: 20px;
font-family: Arial, sans-serif;
}
.demo-container {
max-width: 1200px;
margin: 0 auto;
}
.canvas-wrapper {
height: 600px;
border: 1px solid #ddd;
margin: 20px 0;
}
.controls {
margin-bottom: 20px;
}
</style>
</head>
<body>
<div id="app">
<div class="demo-container">
<h1>Simple Rendering Canvas Demo</h1>
<div class="controls" v-if="canvas">
<!-- View dropdown component -->
<rendering-canvas-view-dropdown
:rendering-canvas="canvas"
></rendering-canvas-view-dropdown>
</div>
<div class="canvas-wrapper">
<!-- Self-contained canvas component -->
<rendering-canvas
ref="canvas"
:auto-connect="true"
@initialized="onCanvasInitialized"
@server-initialized="onServerInitialized"
></rendering-canvas>
</div>
</div>
</div>
<script type="module">
import RenderingCanvas from './disp/rendering-canvas.js';
import RenderingCanvasViewDropdown from './disp/rendering-canvas-view-dropdown.js';
const { createApp } = Vue;
createApp({
components: {
RenderingCanvas,
RenderingCanvasViewDropdown
},
data() {
return {
canvas: null
}
},
mounted() {
// Make the canvas reference available after mounting
this.$nextTick(() => {
this.canvas = this.$refs.canvas;
});
},
methods: {
onCanvasInitialized(canvasId) {
console.log('Canvas initialized:', canvasId);
// Update the canvas reference to ensure dropdown is enabled
this.canvas = this.$refs.canvas;
},
async onServerInitialized(sessionId) {
console.log('Server initialized with session:', sessionId);
// Load test objects for this demo
await this.loadTestObjects(sessionId);
},
async loadTestObjects(sessionId) {
try {
const response = await fetch(`/api/rendering/test-objects/${sessionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
console.log('Test objects loaded successfully');
} else {
const errorText = await response.text();
console.error(`Failed to load test objects: ${response.status} - ${errorText}`);
}
} catch (err) {
console.error(`Error loading test objects: ${err}`);
}
}
}
}).mount('#app');
</script>
</body>
</html>

View File

@ -0,0 +1,185 @@
export default {
name: 'RenderingCanvasViewDropdown',
template: `
<div class="view-dropdown">
<button
type="button"
class="btn btn-info dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
v-if="useBootstrap"
>
<span class="oi oi-magnifying-glass">🔍</span>
</button>
<ul class="dropdown-menu" v-if="useBootstrap">
<li><h6 class="dropdown-header">{{ viewLabel }}</h6></li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="frontLabel + ' (F1)'"
@click="setView('front')"
:disabled="!canvasReady"
>{{ frontLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="backLabel"
@click="setView('back')"
:disabled="!canvasReady"
>{{ backLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="rightLabel + ' (F2)'"
@click="setView('right')"
:disabled="!canvasReady"
>{{ rightLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="leftLabel"
@click="setView('left')"
:disabled="!canvasReady"
>{{ leftLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="topLabel + ' (F3)'"
@click="setView('top')"
:disabled="!canvasReady"
>{{ topLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="bottomLabel"
@click="setView('bottom')"
:disabled="!canvasReady"
>{{ bottomLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="isometricLabel + ' (F4)'"
@click="setView('isometric')"
:disabled="!canvasReady"
>{{ isometricLabel }}</button>
</li>
</ul>
<!-- Non-Bootstrap version -->
<div class="view-controls" v-if="!useBootstrap">
<button @click="setView('front')" :disabled="!canvasReady">{{ frontLabel }}</button>
<button @click="setView('back')" :disabled="!canvasReady">{{ backLabel }}</button>
<button @click="setView('left')" :disabled="!canvasReady">{{ leftLabel }}</button>
<button @click="setView('right')" :disabled="!canvasReady">{{ rightLabel }}</button>
<button @click="setView('top')" :disabled="!canvasReady">{{ topLabel }}</button>
<button @click="setView('bottom')" :disabled="!canvasReady">{{ bottomLabel }}</button>
<button @click="setView('isometric')" :disabled="!canvasReady">{{ isometricLabel }}</button>
<button @click="setView('home')" :disabled="!canvasReady">{{ homeLabel }}</button>
</div>
</div>
`,
props: {
renderingCanvas: {
type: Object,
default: null
},
useBootstrap: {
type: Boolean,
default: false
},
locale: {
type: String,
default: 'zh-TW'
}
},
computed: {
canvasReady() {
return this.renderingCanvas && this.renderingCanvas.isInitialized && this.renderingCanvas.isConnected;
},
// Localization
viewLabel() {
return this.getLocalizedText('view', '視圖');
},
frontLabel() {
return this.getLocalizedText('front', '前視圖');
},
backLabel() {
return this.getLocalizedText('back', '後視圖');
},
leftLabel() {
return this.getLocalizedText('left', '左視圖');
},
rightLabel() {
return this.getLocalizedText('right', '右視圖');
},
topLabel() {
return this.getLocalizedText('top', '頂視圖');
},
bottomLabel() {
return this.getLocalizedText('bottom', '底視圖');
},
isometricLabel() {
return this.getLocalizedText('isometric', '等角視圖');
},
homeLabel() {
return this.getLocalizedText('home', '主視圖');
}
},
methods: {
async setView(viewType) {
if (this.renderingCanvas && this.renderingCanvas.setView) {
try {
await this.renderingCanvas.setView(viewType);
} catch (err) {
console.error(`Failed to set view: ${err}`);
}
} else {
console.warn('No rendering canvas component provided or setView method not available');
}
},
getLocalizedText(key, defaultText) {
// Simple localization - can be extended with actual i18n library
const translations = {
'en-US': {
view: 'View',
front: 'Front',
back: 'Back',
left: 'Left',
right: 'Right',
top: 'Top',
bottom: 'Bottom',
isometric: 'Isometric',
home: 'Home'
},
'zh-TW': {
view: '視圖',
front: '前視圖',
back: '後視圖',
left: '左視圖',
right: '右視圖',
top: '頂視圖',
bottom: '底視圖',
isometric: '等角視圖',
home: '主視圖'
}
};
const localeTranslations = translations[this.locale] || translations['zh-TW'];
return localeTranslations[key] || defaultText;
}
}
}

View File

@ -0,0 +1,38 @@
/* Rendering Canvas Component Styles */
.canvas-container {
position: relative;
width: 100%;
height: 100%;
background-color: #f8f8f8;
}
.canvas-container canvas {
background-color: rgba(204, 204, 204, 0.5);
touch-action: none;
width: 100%;
height: 100%;
object-fit: none;
border: 0;
padding: 0;
margin: 0;
display: block;
cursor: grab;
outline: none;
transition: box-shadow 0.3s, border-color 0.3s;
}
.canvas-container canvas:focus {
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.4);
}
.canvas-container canvas:active {
cursor: grabbing;
}
/* Context menu disabled */
.canvas-container canvas {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

View File

@ -0,0 +1,490 @@
// Add CSS link to the document if not already present
if (!document.querySelector('link[href*="rendering-canvas.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = './disp/rendering-canvas.css';
document.head.appendChild(link);
}
export default {
name: 'RenderingCanvas',
template: `
<div class="canvas-container">
<canvas
ref="renderCanvas"
:id="canvasId"
tabindex="0"
@contextmenu.prevent
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@wheel.prevent="handleWheel"
@keydown="handleKeyDown"
@keyup="handleKeyUp"
@touchstart.prevent="handleTouchStart"
@touchmove.prevent="handleTouchMove"
@touchend.prevent="handleTouchEnd"
@click="focusCanvas"
@focus="onFocus"
@blur="onBlur"
></canvas>
</div>
`,
props: {
hubUrl: {
type: String,
default: '/renderingHub'
},
autoConnect: {
type: Boolean,
default: true
},
width: {
type: Number,
default: 800
},
height: {
type: Number,
default: 600
}
},
data() {
return {
canvasId: `rendering-canvas-${Math.random().toString(36).substr(2, 9)}`,
connection: null,
sessionId: null,
isConnected: false,
isInitialized: false,
hasFocus: false,
mouseState: {
isDown: false,
button: 0,
lastX: 0,
lastY: 0
},
compressionSupported: typeof DecompressionStream !== 'undefined',
isFirefox: navigator.userAgent.toLowerCase().indexOf('firefox') > -1,
resizeObserver: null
}
},
async mounted() {
await this.setupCanvas();
this.setupResizeObserver();
if (this.autoConnect) {
await this.connect();
}
// Add visibility change listener
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
document.addEventListener('visibilitychange', this.handleVisibilityChange);
},
async beforeUnmount() {
// Remove visibility change listener
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
if (this.connection) {
await this.disconnect();
}
},
watch: {
// No longer needed since we manage connection internally
},
methods: {
async connect() {
try {
// Create SignalR connection
this.connection = new signalR.HubConnectionBuilder()
.withUrl(this.hubUrl)
.configureLogging(signalR.LogLevel.Information)
.build();
// Set up event handlers
this.connection.on("ImageUpdate", (compressedData, originalLength, width, height) => {
this.handleImageUpdate(compressedData, originalLength, width, height);
});
this.connection.on("CanvasInitialized", (id) => {
this.sessionId = id;
this.$emit('serverInitialized', id);
});
// Start connection
await this.connection.start();
this.isConnected = true;
this.$emit('connected');
// Initialize server canvas
const canvas = this.$refs.renderCanvas;
const rect = canvas.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
await this.connection.invoke("InitializeCanvas", width, height);
} catch (err) {
this.$emit('error', `Connection failed: ${err}`);
console.error('Connection failed:', err);
// Retry after 5 seconds
setTimeout(() => this.connect(), 5000);
}
},
async disconnect() {
if (this.connection) {
try {
await this.connection.stop();
this.connection = null;
this.sessionId = null;
this.isConnected = false;
// Clear canvas
const canvas = this.$refs.renderCanvas;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.$emit('disconnected');
} catch (err) {
console.error('Disconnect failed:', err);
}
}
},
async setupCanvas() {
const canvas = this.$refs.renderCanvas;
canvas.width = this.width;
canvas.height = this.height;
this.isInitialized = true;
this.$emit('initialized', this.canvasId);
},
setupResizeObserver() {
this.resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
const { width, height } = entry.contentRect;
this.handleResize(width, height);
}
});
this.resizeObserver.observe(this.$refs.renderCanvas.parentElement);
},
async handleResize(width, height) {
const canvas = this.$refs.renderCanvas;
canvas.width = Math.floor(width);
canvas.height = Math.floor(height);
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleResize", canvas.width, canvas.height);
} catch (err) {
this.$emit('error', `Resize failed: ${err}`);
}
}
},
async handleVisibilityChange() {
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleVisibilityChange", document.visibilityState);
this.$emit('visibilityChanged', document.visibilityState);
} catch (err) {
this.$emit('error', `Visibility change failed: ${err}`);
}
}
},
focusCanvas() {
this.$refs.renderCanvas.focus();
},
onFocus() {
this.hasFocus = true;
this.$emit('focus');
},
onBlur() {
this.hasFocus = false;
this.$emit('blur');
},
// Mouse event handlers
async handleMouseDown(e) {
if (!this.isInitialized || !this.connection) return;
this.mouseState.isDown = true;
this.mouseState.button = e.button;
const rect = this.$refs.renderCanvas.getBoundingClientRect();
this.mouseState.lastX = e.clientX - rect.left;
this.mouseState.lastY = e.clientY - rect.top;
if (this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleMouseDown",
this.mouseState.lastX,
this.mouseState.lastY,
e.button
);
} catch (err) {
this.$emit('error', `Mouse down failed: ${err}`);
}
}
},
async handleMouseMove(e) {
if (!this.isInitialized) return;
const rect = this.$refs.renderCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const buttonMask = this.mouseState.isDown ? (1 << this.mouseState.button) : 0;
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleMouseMove", x, y, buttonMask);
} catch (err) {
this.$emit('error', `Mouse move failed: ${err}`);
}
}
this.mouseState.lastX = x;
this.mouseState.lastY = y;
},
async handleMouseUp(e) {
if (!this.isInitialized) return;
this.mouseState.isDown = false;
const rect = this.$refs.renderCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleMouseUp", x, y, e.button);
} catch (err) {
this.$emit('error', `Mouse up failed: ${err}`);
}
}
},
async handleWheel(e) {
if (!this.isInitialized) return;
const rect = this.$refs.renderCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const browserBrand = this.isFirefox ? 'firefox' : 'chrome';
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleMouseWheel",
x, y, e.deltaX, e.deltaY, browserBrand
);
} catch (err) {
this.$emit('error', `Mouse wheel failed: ${err}`);
}
}
},
// Keyboard event handlers
async handleKeyDown(e) {
if (!this.isInitialized) return;
e.preventDefault();
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleKeyDown",
e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey
);
} catch (err) {
this.$emit('error', `Key down failed: ${err}`);
}
}
},
async handleKeyUp(e) {
if (!this.isInitialized) return;
e.preventDefault();
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleKeyUp",
e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey
);
} catch (err) {
this.$emit('error', `Key up failed: ${err}`);
}
}
},
// Touch event handlers
async handleTouchStart(e) {
if (!this.isInitialized) return;
const touch = e.touches[0];
const rect = this.$refs.renderCanvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleTouchDown", touch.identifier, x, y);
} catch (err) {
this.$emit('error', `Touch start failed: ${err}`);
}
}
},
async handleTouchMove(e) {
if (!this.isInitialized) return;
const touch = e.touches[0];
const rect = this.$refs.renderCanvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleTouchMove", touch.identifier, x, y);
} catch (err) {
this.$emit('error', `Touch move failed: ${err}`);
}
}
},
async handleTouchEnd(e) {
if (!this.isInitialized) return;
const touch = e.changedTouches[0];
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleTouchUp", touch.identifier);
} catch (err) {
this.$emit('error', `Touch end failed: ${err}`);
}
}
},
// Image update handler
async handleImageUpdate(compressedData, originalLength, width, height) {
try {
let imageData;
if (this.compressionSupported) {
const decompressed = await this.decompressData(compressedData, originalLength);
imageData = new Uint8ClampedArray(decompressed);
} else {
imageData = new Uint8ClampedArray(compressedData);
}
const canvas = this.$refs.renderCanvas;
const ctx = canvas.getContext('2d');
const imgData = new ImageData(imageData, width, height);
ctx.putImageData(imgData, 0, 0);
this.$emit('imageUpdated');
} catch (err) {
this.$emit('error', `Image update failed: ${err}`);
}
},
async decompressData(compressedData, originalLength) {
let bytes;
if (compressedData instanceof Uint8Array) {
bytes = compressedData;
} else if (compressedData instanceof ArrayBuffer) {
bytes = new Uint8Array(compressedData);
} else if (typeof compressedData === 'string') {
const binaryString = atob(compressedData);
bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
} else {
throw new Error(`Unknown compressed data type: ${typeof compressedData}`);
}
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;
},
// Public methods that can be called from parent
async setView(viewType) {
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke('SetView', viewType);
this.$emit('viewChanged', viewType);
} catch (err) {
this.$emit('error', `Set view failed: ${err}`);
}
}
},
// Public method to manually connect if autoConnect is false
async manualConnect() {
if (!this.isConnected) {
await this.connect();
}
},
// Public method to manually disconnect
async manualDisconnect() {
await this.disconnect();
},
// Getters for component state
getConnectionState() {
return {
isConnected: this.isConnected,
sessionId: this.sessionId,
connectionState: this.connection ? this.connection.state : 'Not initialized'
};
},
// Get session ID
getSessionId() {
return this.sessionId;
}
}
}