diff --git a/Hubs/RenderingHub.cs b/Hubs/RenderingHub.cs index 3b72416..73ac795 100644 --- a/Hubs/RenderingHub.cs +++ b/Hubs/RenderingHub.cs @@ -34,9 +34,6 @@ namespace Hi.Webapi.Hubs var engine = _renderingService.GetOrCreateEngine(sessionId); _logger.LogInformation($"Engine created/retrieved - SessionId: {sessionId}"); - // 創建並設置測試顯示對象 - _logger.LogInformation($"TestDisplayee set - SessionId: {sessionId}"); - // 啟動引擎(必須在設置回調之前) engine.Start(width, height); _logger.LogInformation($"Engine started with size {width}x{height} - SessionId: {sessionId}"); @@ -50,7 +47,7 @@ namespace Hi.Webapi.Hubs // 設置渲染回調 unsafe { - engine.ImageRequestAfterBufferSwapped += (bgra, w, h) => + engine.ImageRequestAfterBufferSwapped += (byte* bgra, int w, int h) => { if (bgra == null) return; diff --git a/Program.cs b/Program.cs index f11d784..0224031 100644 --- a/Program.cs +++ b/Program.cs @@ -1,3 +1,7 @@ +using Hi.Disp; +using Hi.Licenses; +using Hi.Webapi.Hubs; +using Hi.Webapi.Services; namespace Hi.Webapi { @@ -5,44 +9,76 @@ namespace Hi.Webapi { public static void Main(string[] args) { + License.LogInAll(); + DispEngine.Init(); var builder = WebApplication.CreateBuilder(args); // Add services to the container. - builder.Services.AddAuthorization(); - + builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); + builder.Services.AddEndpointsApiExplorer(); + + // 添加 SignalR + builder.Services.AddSignalR(options => + { + options.MaximumReceiveMessageSize = 10 * 1024 * 1024; // 10MB + }); + + // 添加 CORS + builder.Services.AddCors(options => + { + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + // 註冊 RenderingService + builder.Services.AddSingleton(); + + // 配置允許 unsafe 代碼 + builder.Services.Configure(options => + { + options.AllowSynchronousIO = true; + }); var app = builder.Build(); + // Configure application lifetime events + var lifetime = app.Services.GetRequiredService(); + lifetime.ApplicationStopping.Register(() => + { + DispEngine.FinishDisp(); + License.LogOutAll(); + Console.WriteLine($"App exit."); + }); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } - app.UseHttpsRedirection(); + // 在開發環境中不使用 HTTPS 重定向 + // app.UseHttpsRedirection(); + + // 添加靜態文件支援 + app.UseStaticFiles(); + + app.UseCors("AllowAll"); app.UseAuthorization(); - var summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; + app.MapControllers(); - app.MapGet("/weatherforecast", (HttpContext httpContext) => - { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast"); + // 映射 SignalR Hub + app.MapHub("/renderingHub"); + + // 添加一個簡單的首頁 + app.MapGet("/", () => Results.Redirect("/demo-vue.html")); app.Run(); } diff --git a/WeatherForecast.cs b/WeatherForecast.cs deleted file mode 100644 index 66f73fe..0000000 --- a/WeatherForecast.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Hi.Webapi -{ - public class WeatherForecast - { - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } - } -} 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.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