init
This commit is contained in:
commit
68c8096686
270
.gitignore
vendored
Normal file
270
.gitignore
vendored
Normal file
@ -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/*
|
173
Controllers/RenderingController.cs
Normal file
173
Controllers/RenderingController.cs
Normal file
@ -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<RenderingController> _logger;
|
||||
|
||||
public RenderingController(RenderingService renderingService, ILogger<RenderingController> logger)
|
||||
{
|
||||
_renderingService = renderingService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取當前活動的渲染引擎數量
|
||||
/// </summary>
|
||||
[HttpGet("engines/count")]
|
||||
public IActionResult GetActiveEngineCount()
|
||||
{
|
||||
var count = _renderingService.GetActiveEngineCount();
|
||||
return Ok(new { count });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 創建測試用的 3D 對象
|
||||
/// </summary>
|
||||
[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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 測試用的顯示對象
|
||||
/// </summary>
|
||||
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<double>();
|
||||
|
||||
// 黃色立方體
|
||||
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<double>();
|
||||
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<double> 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));
|
||||
}
|
||||
}
|
||||
}
|
BIN
Font/WCL06.ttf
Normal file
BIN
Font/WCL06.ttf
Normal file
Binary file not shown.
38
Hi.Sample.Webapi.csproj
Normal file
38
Hi.Sample.Webapi.csproj
Normal file
@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Platforms>x64</Platforms>
|
||||
<AssemblyName>Hi.Sample.Webapi</AssemblyName>
|
||||
<RootNamespace>Sample</RootNamespace>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Configurations>Debug;Release</Configurations>
|
||||
<VersionBuild>0</VersionBuild>
|
||||
<VersionPrefix>3.1.$(VersionBuild)</VersionPrefix>
|
||||
<Product>$(AssemblyName)</Product>
|
||||
<OutputPath>bin\$(Platform).$(Configuration)\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<ProjectReference Include="..\HiLicense\HiLicense.csproj" />
|
||||
<ProjectReference Include="..\HiGeom\HiGeom.csproj" />
|
||||
<ProjectReference Include="..\HiDisp\HiDisp.csproj" />
|
||||
<ProjectReference Include="..\HiCbtr\HiCbtr.csproj" />
|
||||
<ProjectReference Include="..\HiMech\HiMech.csproj" />
|
||||
<ProjectReference Include="..\HiUniNc\HiUniNc.csproj" />
|
||||
<ProjectReference Include="..\HiNc\HiNc.csproj" />
|
||||
<ProjectReference Include="..\Hi.Webapi\Hi.Webapi.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(Configuration)'=='Release'">
|
||||
<PackageReference Include="HiNc" Version="3.1.*" />
|
||||
<PackageReference Include="Hi.Webapi" Version="3.1.*" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
76
Program.cs
Normal file
76
Program.cs
Normal file
@ -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<RenderingService>();
|
||||
|
||||
// 配置允許 unsafe 代碼
|
||||
builder.Services.Configure<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>(options =>
|
||||
{
|
||||
options.AllowSynchronousIO = true;
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure application lifetime events
|
||||
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
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>("/renderingHub");
|
||||
|
||||
// 添加一個簡單的首頁
|
||||
app.MapGet("/", () => Results.Redirect("/demo-vue.html"));
|
||||
|
||||
app.Run();
|
14
Properties/launchSettings.json
Normal file
14
Properties/launchSettings.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
107
README.md
Normal file
107
README.md
Normal file
@ -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 專案的實作方式。本範例使用內嵌方式是為了保持簡單和獨立性。
|
8
appsettings.Development.json
Normal file
8
appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
9
appsettings.json
Normal file
9
appsettings.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
734
wwwroot/demo-native.html
Normal file
734
wwwroot/demo-native.html
Normal 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>
|
325
wwwroot/demo-vue-inline.html
Normal file
325
wwwroot/demo-vue-inline.html
Normal file
@ -0,0 +1,325 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vue.js Inline Rendering Canvas Demo</title>
|
||||
<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;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.demo-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
height: 600px;
|
||||
border: 1px solid #ddd;
|
||||
margin: 20px 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.rendering-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rendering-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.view-button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #2196F3;
|
||||
background: white;
|
||||
color: #2196F3;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.view-button:hover {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="demo-container">
|
||||
<h1>Vue.js 內嵌式渲染畫布示範</h1>
|
||||
|
||||
<div class="status" :class="isConnected ? 'connected' : 'disconnected'">
|
||||
{{ isConnected ? '已連接' : '未連接' }}
|
||||
<span v-if="sessionId">- Session: {{ sessionId }}</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="view-button" @click="setView('front')">前視圖</button>
|
||||
<button class="view-button" @click="setView('back')">後視圖</button>
|
||||
<button class="view-button" @click="setView('left')">左視圖</button>
|
||||
<button class="view-button" @click="setView('right')">右視圖</button>
|
||||
<button class="view-button" @click="setView('top')">頂視圖</button>
|
||||
<button class="view-button" @click="setView('bottom')">底視圖</button>
|
||||
<button class="view-button" @click="setView('isometric')">等角視圖</button>
|
||||
<button class="view-button" @click="setView('home')">主視圖</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-wrapper">
|
||||
<canvas ref="canvas" class="rendering-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<p>提示:使用滑鼠拖曳旋轉視圖,滾輪縮放,鍵盤 F1-F4 切換視圖</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
connection: null,
|
||||
sessionId: null,
|
||||
isConnected: false,
|
||||
canvas: null,
|
||||
ctx: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.canvas = this.$refs.canvas;
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.connect();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.connection) {
|
||||
this.connection.stop();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async connect() {
|
||||
try {
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/renderingHub")
|
||||
.configureLogging(signalR.LogLevel.Information)
|
||||
.build();
|
||||
|
||||
// 設置消息處理器
|
||||
this.connection.on("ImageUpdate", (compressedData, originalLength, width, height) => {
|
||||
this.handleImageUpdate(compressedData, originalLength, width, height);
|
||||
});
|
||||
|
||||
this.connection.on("CanvasInitialized", (id) => {
|
||||
this.sessionId = id;
|
||||
console.log("畫布初始化完成,會話ID:", id);
|
||||
this.loadTestObjects();
|
||||
});
|
||||
|
||||
// 連接到服務器
|
||||
await this.connection.start();
|
||||
this.isConnected = true;
|
||||
console.log("已連接到服務器");
|
||||
|
||||
// 初始化畫布
|
||||
await this.initializeCanvas();
|
||||
|
||||
} catch (err) {
|
||||
console.error("連接失敗:", err);
|
||||
this.isConnected = false;
|
||||
setTimeout(() => this.connect(), 5000);
|
||||
}
|
||||
},
|
||||
|
||||
async initializeCanvas() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const width = Math.floor(rect.width);
|
||||
const height = Math.floor(rect.height);
|
||||
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
|
||||
await this.connection.invoke("InitializeCanvas", width, height);
|
||||
|
||||
// 設置事件處理
|
||||
this.setupEvents();
|
||||
},
|
||||
|
||||
setupEvents() {
|
||||
// 滑鼠事件
|
||||
let isMouseDown = false;
|
||||
let mouseButton = 0;
|
||||
|
||||
this.canvas.addEventListener('mousedown', async (e) => {
|
||||
isMouseDown = true;
|
||||
mouseButton = e.button;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
await this.connection.invoke("HandleMouseDown", x, y, e.button);
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('mousemove', async (e) => {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const buttonMask = isMouseDown ? (1 << mouseButton) : 0;
|
||||
await this.connection.invoke("HandleMouseMove", x, y, buttonMask);
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('mouseup', async (e) => {
|
||||
isMouseDown = false;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
await this.connection.invoke("HandleMouseUp", x, y, e.button);
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('wheel', async (e) => {
|
||||
e.preventDefault();
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
await this.connection.invoke("HandleMouseWheel", x, y, e.deltaX, e.deltaY, "chrome");
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// 鍵盤事件
|
||||
this.canvas.tabIndex = 0;
|
||||
this.canvas.addEventListener('keydown', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.connection.invoke("HandleKeyDown", e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey);
|
||||
});
|
||||
},
|
||||
|
||||
async handleImageUpdate(compressedData, originalLength, width, height) {
|
||||
try {
|
||||
// 解壓縮數據
|
||||
const imageData = await this.decompressData(compressedData, originalLength);
|
||||
|
||||
// 創建 ImageData 對象並繪製到畫布
|
||||
const imgData = new ImageData(new Uint8ClampedArray(imageData), width, height);
|
||||
this.ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
} catch (err) {
|
||||
console.error("處理圖像更新時出錯:", err);
|
||||
}
|
||||
},
|
||||
|
||||
async decompressData(compressedData, originalLength) {
|
||||
let bytes;
|
||||
|
||||
if (typeof compressedData === 'string') {
|
||||
// Base64 字符串轉換
|
||||
const binaryString = atob(compressedData);
|
||||
bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
} else {
|
||||
bytes = new Uint8Array(compressedData);
|
||||
}
|
||||
|
||||
// 使用 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;
|
||||
},
|
||||
|
||||
async setView(viewType) {
|
||||
if (this.connection && this.connection.state === 'Connected') {
|
||||
try {
|
||||
await this.connection.invoke('SetView', viewType);
|
||||
console.log(`視圖已切換到: ${viewType}`);
|
||||
} catch (err) {
|
||||
console.error(`切換視圖失敗: ${err}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async loadTestObjects() {
|
||||
try {
|
||||
const response = await fetch(`/api/rendering/test-objects/${this.sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('測試對象載入成功');
|
||||
} else {
|
||||
console.error('載入測試對象失敗');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('載入測試對象時出錯:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
115
wwwroot/demo-vue.html
Normal file
115
wwwroot/demo-vue.html
Normal 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>
|
185
wwwroot/disp/rendering-canvas-view-dropdown.js
Normal file
185
wwwroot/disp/rendering-canvas-view-dropdown.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
38
wwwroot/disp/rendering-canvas.css
Normal file
38
wwwroot/disp/rendering-canvas.css
Normal 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;
|
||||
}
|
490
wwwroot/disp/rendering-canvas.js
Normal file
490
wwwroot/disp/rendering-canvas.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user