commit 68c80966867d57c556368cef254d6a312f2100a5 Author: iambossTC Date: Fri Jul 18 20:25:57 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee11161 --- /dev/null +++ b/.gitignore @@ -0,0 +1,270 @@ +!*.lib + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +!hasp*.exe +!hasp*.dll +!HiLock.dll +!Aladdin*.dll +Cache/* +*/Cache/* \ No newline at end of file diff --git a/Controllers/RenderingController.cs b/Controllers/RenderingController.cs new file mode 100644 index 0000000..a1a7350 --- /dev/null +++ b/Controllers/RenderingController.cs @@ -0,0 +1,173 @@ +using Microsoft.AspNetCore.Mvc; +using Hi.Webapi.Services; +using Hi.Disp; +using Hi.Geom; + +namespace Sample.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class RenderingController : ControllerBase + { + private readonly RenderingService _renderingService; + private readonly ILogger _logger; + + public RenderingController(RenderingService renderingService, ILogger logger) + { + _renderingService = renderingService; + _logger = logger; + } + + /// + /// 獲取當前活動的渲染引擎數量 + /// + [HttpGet("engines/count")] + public IActionResult GetActiveEngineCount() + { + var count = _renderingService.GetActiveEngineCount(); + return Ok(new { count }); + } + + /// + /// 創建測試用的 3D 對象 + /// + [HttpPost("test-objects/{sessionId}")] + public IActionResult CreateTestObjects(string sessionId) + { + _logger.LogInformation($"CreateTestObjects called - SessionId: {sessionId}"); + + try + { + var engine = _renderingService.GetOrCreateEngine(sessionId); + _logger.LogInformation($"Engine retrieved - SessionId: {sessionId}"); + + // 創建一個簡單的測試顯示對象 + var testDisplayee = new TestDisplayee(); + engine.Displayee = testDisplayee; + _logger.LogInformation($"TestDisplayee created and set - SessionId: {sessionId}"); + + // 觸發一次渲染 + engine.IsVisible = true; + _logger.LogInformation($"Engine visibility set to true to trigger render - SessionId: {sessionId}"); + + return Ok(new { + message = "Test objects created successfully", + sessionId = sessionId, + timestamp = DateTime.Now + }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error creating test objects - SessionId: {sessionId}"); + return StatusCode(500, new { + error = ex.Message, + sessionId = sessionId, + timestamp = DateTime.Now + }); + } + } + } + + /// + /// 測試用的顯示對象 + /// + public class TestDisplayee : IDisplayee + { + private readonly Drawing axesDrawing; + private readonly Drawing cubeDrawing; + private readonly Drawing groundDrawing; + + public TestDisplayee() + { + // 創建彩色坐標軸 (使用 CV stamp - Color + Vertex) + double[] axesData = new double[] { + // Red X-axis (color + vertex) + 1, 0, 0, 0, 0, 0, // Red color, origin + 1, 0, 0, 200, 0, 0, // Red color, x-axis end + + // Green Y-axis (color + vertex) + 0, 1, 0, 0, 0, 0, // Green color, origin + 0, 1, 0, 0, 200, 0, // Green color, y-axis end + + // Blue Z-axis (color + vertex) + 0, 0, 1, 0, 0, 0, // Blue color, origin + 0, 0, 1, 0, 0, 200 // Blue color, z-axis end + }; + + axesDrawing = new Drawing(axesData, Stamp.CV, Hi.Disp.GL.GL_LINES); + + // 創建立方體線框 (使用 CV stamp - 帶顏色) + var size = 100.0; + var cubeData = new System.Collections.Generic.List(); + + // 黃色立方體 + var color = new[] { 1.0, 1.0, 0.0 }; // 黃色 + + // 底面四條線 + AddColoredLine(cubeData, color, -size/2, -size/2, -size/2, size/2, -size/2, -size/2); + AddColoredLine(cubeData, color, size/2, -size/2, -size/2, size/2, size/2, -size/2); + AddColoredLine(cubeData, color, size/2, size/2, -size/2, -size/2, size/2, -size/2); + AddColoredLine(cubeData, color, -size/2, size/2, -size/2, -size/2, -size/2, -size/2); + + // 頂面四條線 + AddColoredLine(cubeData, color, -size/2, -size/2, size/2, size/2, -size/2, size/2); + AddColoredLine(cubeData, color, size/2, -size/2, size/2, size/2, size/2, size/2); + AddColoredLine(cubeData, color, size/2, size/2, size/2, -size/2, size/2, size/2); + AddColoredLine(cubeData, color, -size/2, size/2, size/2, -size/2, -size/2, size/2); + + // 垂直線 + AddColoredLine(cubeData, color, -size/2, -size/2, -size/2, -size/2, -size/2, size/2); + AddColoredLine(cubeData, color, size/2, -size/2, -size/2, size/2, -size/2, size/2); + AddColoredLine(cubeData, color, size/2, size/2, -size/2, size/2, size/2, size/2); + AddColoredLine(cubeData, color, -size/2, size/2, -size/2, -size/2, size/2, size/2); + + cubeDrawing = new Drawing(cubeData.ToArray(), Stamp.CV, Hi.Disp.GL.GL_LINES); + + // 創建地面網格 + var gridData = new System.Collections.Generic.List(); + var gridColor = new[] { 0.3, 0.3, 0.3 }; // 灰色 + var gridSize = 500.0; + var gridStep = 50.0; + + for (double i = -gridSize; i <= gridSize; i += gridStep) + { + // X 方向的線 + AddColoredLine(gridData, gridColor, i, -gridSize, 0, i, gridSize, 0); + // Y 方向的線 + AddColoredLine(gridData, gridColor, -gridSize, i, 0, gridSize, i, 0); + } + + groundDrawing = new Drawing(gridData.ToArray(), Stamp.CV, Hi.Disp.GL.GL_LINES); + } + + private void AddColoredLine(System.Collections.Generic.List data, double[] color, + double x1, double y1, double z1, double x2, double y2, double z2) + { + // 第一個點 + data.Add(color[0]); data.Add(color[1]); data.Add(color[2]); + data.Add(x1); data.Add(y1); data.Add(z1); + // 第二個點 + data.Add(color[0]); data.Add(color[1]); data.Add(color[2]); + data.Add(x2); data.Add(y2); data.Add(z2); + } + + public void Display(Bind bind) + { + // 顯示地面網格 + groundDrawing.Display(bind); + + // 顯示坐標軸 + axesDrawing.Display(bind); + + // 顯示立方體 + cubeDrawing.Display(bind); + } + + public void ExpandToBox3d(Box3d box) + { + // 擴展邊界框以包含我們的測試對象 + box.Expand(new Vec3d(-500, -500, -100)); + box.Expand(new Vec3d(500, 500, 200)); + } + } +} \ No newline at end of file diff --git a/Font/WCL06.ttf b/Font/WCL06.ttf new file mode 100644 index 0000000..969cc5b Binary files /dev/null and b/Font/WCL06.ttf differ diff --git a/Hi.Sample.Webapi.csproj b/Hi.Sample.Webapi.csproj new file mode 100644 index 0000000..115aabf --- /dev/null +++ b/Hi.Sample.Webapi.csproj @@ -0,0 +1,38 @@ + + + + net9.0 + enable + enable + x64 + Hi.Sample.Webapi + Sample + true + Debug;Release + 0 + 3.1.$(VersionBuild) + $(AssemblyName) + bin\$(Platform).$(Configuration)\ + DEBUG;TRACE + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..f0e6178 --- /dev/null +++ b/Program.cs @@ -0,0 +1,76 @@ +using Hi.HiNcKits; +using Hi.Webapi.Hubs; +using Hi.Webapi.Services; + +SingleUserApp.AppBegin(); +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddControllers(); +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// 添加 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(); + +// 配置允許 unsafe 代碼 +builder.Services.Configure(options => +{ + options.AllowSynchronousIO = true; +}); + +var app = builder.Build(); + +// Configure application lifetime events +var lifetime = app.Services.GetRequiredService(); +lifetime.ApplicationStopping.Register(() => +{ + SingleUserApp.AppEnd(); +}); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(); +} + +// 在開發環境中不使用 HTTPS 重定向 +// app.UseHttpsRedirection(); + +// 添加靜態文件支援 +app.UseStaticFiles(); + +app.UseCors("AllowAll"); + +app.UseAuthorization(); + +app.MapControllers(); + +// 映射 SignalR Hub +app.MapHub("/renderingHub"); + +// 添加一個簡單的首頁 +app.MapGet("/", () => Results.Redirect("/demo-vue.html")); + +app.Run(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..3a8f9a0 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f1980c --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Hi.Sample.Webapi + +這是一個展示如何使用 Hi.Webapi 功能的範例專案。 + +## 專案描述 + +本專案示範了如何: +- 使用 Hi.Webapi 的 RenderingService 和 RenderingHub +- 建立自訂的 Controller 來創建測試 3D 物件 +- 整合 SignalR 進行即時渲染通訊 +- 實作 IDisplayee 介面來定義自訂的 3D 物件 + +## 功能特點 + +- **3D 物件渲染**:使用 Hi.Disp 引擎渲染 3D 圖形 +- **即時通訊**:透過 SignalR 實現客戶端與服務器的即時互動 +- **測試物件**:包含坐標軸、立方體和地面網格的示範物件 +- **WebGL 渲染**:使用 HTML5 Canvas 進行硬體加速渲染 +- **多種前端範例**:提供原生 JavaScript 和 Vue.js 兩種實作方式 + +## 執行專案 + +### 前置需求 +- .NET 9.0 SDK +- Visual Studio 2022 或 VS Code + +### 啟動步驟 + +1. 在專案目錄下執行: + ```bash + dotnet build + dotnet run + ``` + +2. 開啟瀏覽器訪問: + - 原生 JavaScript 版本:http://localhost:5180/demo-native.html + - Vue.js 內嵌版本:http://localhost:5180/demo-vue-inline.html + +## 專案結構 + +``` +Hi.Sample.Webapi/ +├── Controllers/ +│ └── RenderingController.cs # 範例控制器,展示如何創建測試物件 +├── wwwroot/ +│ ├── demo-native.html # 原生 JavaScript 範例頁面 +│ └── demo-vue-inline.html # Vue.js 內嵌式範例頁面 +├── Program.cs # 應用程式進入點 +├── appsettings.json # 應用程式設定 +├── Properties/ +│ └── launchSettings.json # 啟動設定 +└── Hi.Sample.Webapi.csproj # 專案檔案 +``` + +## 使用說明 + +### 鍵盤控制 +- **F1-F4**:切換不同視圖 +- **方向鍵**:旋轉視圖 +- **PageUp/PageDown**:縮放 +- **Home**:重置視圖 + +### 滑鼠控制 +- **左鍵拖曳**:旋轉視圖 +- **右鍵拖曳**:平移視圖 +- **滾輪**:縮放 + +## API 端點 + +- `GET /api/rendering/engines/count` - 獲取活動的渲染引擎數量 +- `POST /api/rendering/test-objects/{sessionId}` - 為指定會話創建測試物件 + +## 重要類別說明 + +### TestDisplayee +實作 IDisplayee 介面的範例類別,展示如何創建自訂的 3D 物件: +- 使用 Drawing 類別創建圖形元素 +- 實作 Display 方法來渲染物件 +- 實作 ExpandToBox3d 方法來定義物件邊界 + +## 前端實作比較 + +### demo-native.html +- 使用原生 JavaScript +- 詳細的實作範例 +- 適合學習底層 API 使用 + +### demo-vue-inline.html +- 使用 Vue.js 3 +- 所有程式碼內嵌在單一 HTML 檔案中 +- 展示如何用 Vue.js 整合渲染功能 +- 無需外部依賴檔案,方便複製使用 + +## 依賴項目 + +- Hi.Webapi - 提供 Web API 和 SignalR 支援 +- HiNc - 核心 NC 功能 +- HiDisp - 顯示引擎 +- HiGeom - 幾何運算 +- 其他 HiAPI 相關專案 + +## 注意事項 + +這是一個範例專案,主要用於展示 Hi.Webapi 的使用方式。實際專案中可能需要根據具體需求進行調整和擴展。 + +### 關於 Vue.js 元件 +如果您需要使用模組化的 Vue.js 元件(如 Hi.Webapi 專案中的 rendering-canvas.js),請參考 Hi.Webapi 專案的實作方式。本範例使用內嵌方式是為了保持簡單和獨立性。 \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..f45087c --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Debug" + } + } +} \ No newline at end of file diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..5687af1 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/wwwroot/demo-native.html b/wwwroot/demo-native.html new file mode 100644 index 0000000..82882c1 --- /dev/null +++ b/wwwroot/demo-native.html @@ -0,0 +1,734 @@ + + + + + UJoin HiNC Demo + + + +
+

HiNC WebAPI 渲染畫布示例

+ +
+ 提示:點擊畫布以啟用鍵盤控制。畫布獲得焦點時邊框會變成綠色。 +
鍵盤控制:F1-F4切換視圖,方向鍵旋轉視圖,PageUp/PageDown縮放,Home重置視圖 +
+ +
+ + + + + + + + +
+ +
+ +
+ +
+
+ + + + + \ No newline at end of file diff --git a/wwwroot/demo-vue-inline.html b/wwwroot/demo-vue-inline.html new file mode 100644 index 0000000..cc484c7 --- /dev/null +++ b/wwwroot/demo-vue-inline.html @@ -0,0 +1,325 @@ + + + + + + Vue.js Inline Rendering Canvas Demo + + + + + +
+
+

Vue.js 內嵌式渲染畫布示範

+ +
+ {{ isConnected ? '已連接' : '未連接' }} + - Session: {{ sessionId }} +
+ +
+ + + + + + + + +
+ +
+ +
+ +

提示:使用滑鼠拖曳旋轉視圖,滾輪縮放,鍵盤 F1-F4 切換視圖

+
+
+ + + + \ No newline at end of file diff --git a/wwwroot/demo-vue.html b/wwwroot/demo-vue.html new file mode 100644 index 0000000..e08fde9 --- /dev/null +++ b/wwwroot/demo-vue.html @@ -0,0 +1,115 @@ + + + + + + Simple Vue.js Rendering Canvas Demo + + + + + + +
+
+

Simple Rendering Canvas Demo

+ +
+ + +
+ +
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/wwwroot/disp/rendering-canvas-view-dropdown.js b/wwwroot/disp/rendering-canvas-view-dropdown.js new file mode 100644 index 0000000..5defbde --- /dev/null +++ b/wwwroot/disp/rendering-canvas-view-dropdown.js @@ -0,0 +1,185 @@ +export default { + name: 'RenderingCanvasViewDropdown', + template: ` +
+ + + + +
+ + + + + + + + +
+
+ `, + 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; + } + } +} \ No newline at end of file diff --git a/wwwroot/disp/rendering-canvas.css b/wwwroot/disp/rendering-canvas.css new file mode 100644 index 0000000..f18f9b0 --- /dev/null +++ b/wwwroot/disp/rendering-canvas.css @@ -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; +} \ No newline at end of file diff --git a/wwwroot/disp/rendering-canvas.js b/wwwroot/disp/rendering-canvas.js new file mode 100644 index 0000000..8be1c54 --- /dev/null +++ b/wwwroot/disp/rendering-canvas.js @@ -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: ` +
+ +
+ `, + 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; + } + } +} \ No newline at end of file