import type { Selection, ToolType, FloorPlan, SymbolType, Point } from './core/types'; import type { WallGraphResult } from './engine/wall-graph'; import type { DrawContext } from './engine/ck/types'; import { SkiaResourcePool } from './engine/ck/types'; import { History } from './core/history'; import { Camera } from './engine/camera'; import { render, type PerfTimings } from './engine/renderer'; import { getActiveFloor, createProject, createFloorPlan, addWall } from './model/project'; import { ImageCache } from './engine/image-cache'; import { SelectTool } from './tools/select'; import { WallTool } from './tools/wall'; import { DoorWindowTool } from './tools/door'; import { FurnitureTool } from './tools/furniture'; import { DimensionTool } from './tools/dimension'; import { TextTool } from './tools/text'; import { ArtboardTool } from './tools/artboard'; import { RegionTool } from './tools/region'; import { CalloutTool } from './tools/callout'; import { createCanvasToolbarState, renderCanvasToolbar, hitTestToolbar, hitTestToolbarButton, updateToolbarHover, isToolbarAnimating, isToolButton, isCompoundButton, isCompoundPopupItem, parseCompoundPopupItem, openCompoundPopup, closeAllCompoundPopups, setCompoundActiveSubtype, getCompoundActiveSubtype, syncCompoundFromActiveTool, setGridSnapState, LONG_PRESS_MS, type CanvasToolbarState, type PopupId } from './engine/canvas-toolbar'; import { createPropertiesPanel } from './ui/properties'; import { createLibraryPanel } from './ui/library'; import { initCanvasKit, createSurface } from './engine/ck/init'; import { initTestExports } from './debug/test-exports'; import { initInputHandlers } from './input/input-manager'; import { createPaintCatalog } from './engine/ck/paints'; import { makeTextParagraph, measureInkBounds } from './engine/ck/draw'; import { showExportDialog, type ExportOptions } from './ui/export-dialog'; import { exportArtboardPNG, exportFullScenePNG, exportSelectionPNG, downloadBlob } from './export/png-export'; import type { Tool } from './tools/base'; import { renderAutoplaceOverlay, type AutoplaceOverlayState } from './engine/autoplace-overlay'; import { registerSolver } from './engine/solver-registry'; import { wasmBnbBackend } from './engine/solver-wasm'; import { highsBackend } from './engine/solver-highs'; import { initAutoplaceDiagnostics } from './debug/autoplace-diagnostics'; import { createShortcutOverlayState, toggleShortcutOverlay, renderShortcutOverlay, type ShortcutOverlayState } from './engine/shortcut-overlay'; import { TutorialRunner, renderTutorialOverlay, drawFirstRoom, initVoice, setVoiceSpeed, ensureAudioReady } from './engine/tutorial'; import type { TutorialScript } from './engine/tutorial'; import { startPreloader, stopPreloader } from './preloader'; import './styles/main.css'; // ── Async entry point ────────────────────────────────────────────── async function main() { // Start animated preloader (plain 2D canvas, no CanvasKit dependency) startPreloader(); // Initialize CanvasKit (WASM + fonts) const { ck, fontProvider, typeface, monoTypeface, dimTypeface } = await initCanvasKit(); // Fade out preloader stopPreloader(); // ── State ────────────────────────────────────────────────────────── const project = createProject('My Floor Plan'); const camera = new Camera(); let selection: Selection | null = null; let activeToolName: ToolType = 'select'; let mouseWorld: Point = { x: 0, y: 0 }; let spaceHeld = false; let needsRedraw = true; let showInkOverlay = false; let showOpeningBounds = false; let showPerfOverlay = false; let lastFlushMs = 0; let gridSnap = false; let lastWallGraph: WallGraphResult | null = null; // Autoplace state (used by diagnostics console API) let autoplaceOverlay: AutoplaceOverlayState | null = null; const GRID_SIZE = 10; // Image cache (CanvasKit WASM Image objects, keyed by FloorImage.id) const imageCache = new ImageCache(ck); // ── Canvas & Surface setup ──────────────────────────────────────── const canvas = document.getElementById('canvas') as HTMLCanvasElement; const paints = createPaintCatalog(ck); const pool = new SkiaResourcePool(); let surface = createSurface(ck, canvas); function resizeCanvas(): void { const container = canvas.parentElement!; const dpr = window.devicePixelRatio || 1; const rect = container.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = `${rect.width}px`; canvas.style.height = `${rect.height}px`; // Recreate surface on resize surface.dispose(); surface = createSurface(ck, canvas); needsRedraw = true; } function canvasWidth(): number { return canvas.width / (window.devicePixelRatio || 1); } function canvasHeight(): number { return canvas.height / (window.devicePixelRatio || 1); } // ── History ──────────────────────────────────────────────────────── const history = new History(() => {}); function pushHistory(): void { history.push(getActiveFloor(project)); } function applyFloor(floor: FloorPlan): void { const active = project.floors[project.activeFloor]; active.walls = floor.walls; active.symbols = floor.symbols; active.dimensions = floor.dimensions; active.texts = floor.texts; active.artboards = floor.artboards; active.images = floor.images ?? []; selection = null; needsRedraw = true; } // ── Tools ────────────────────────────────────────────────────────── function getFloor(): FloorPlan { return getActiveFloor(project); } const toolDeps = { getFloor, getSelection: () => selection, setSelection: (s: Selection | null) => { selection = s; propertiesPanel.update(selection, getFloor(), project.unitSystem, () => { needsRedraw = true; }); needsRedraw = true; }, pushHistory, requestRedraw: () => { needsRedraw = true; }, getGridSize: () => GRID_SIZE, zoom: () => camera.state.zoom, setTool: (name: string) => setActiveTool(name as ToolType), screenToWorld: (sx: number, sy: number) => camera.screenToWorld(sx, sy), worldToScreen: (wx: number, wy: number) => camera.worldToScreen(wx, wy), getCanvas: () => canvas, getWallPolygons: () => lastWallGraph?.wallPolygons ?? [], getUnit: () => project.unitSystem, getGridSnap: () => gridSnap, }; const textTool: InstanceType = new TextTool({ ...toolDeps, isActiveTool: (): boolean => activeTool === textTool }); const selectTool = new SelectTool({ ...toolDeps, editTextById: (id, world) => textTool.editById(id, world), isTextEditing: () => textTool.isEditing(), }); const wallTool = new WallTool(toolDeps); const tools: Record = { select: selectTool, wall: wallTool, door: new DoorWindowTool(toolDeps, 'door'), window: new DoorWindowTool(toolDeps, 'window'), furniture: new FurnitureTool(toolDeps), dimension: new DimensionTool(toolDeps), text: textTool, artboard: new ArtboardTool(toolDeps), innerwall: new RegionTool({ ...toolDeps, setActiveTool: (name: ToolType) => setActiveTool(name), toolName: 'innerwall' }), region: new RegionTool({ ...toolDeps, setActiveTool: (name: ToolType) => setActiveTool(name) }), callout: new CalloutTool({ ...toolDeps, activateTextEditing: (textId: string) => { setActiveTool('text'); textTool.editById(textId, undefined, { autoWidth: true }); textTool.setOnEditingDone(() => setActiveTool('callout')); }, setActiveTool: (name: string) => setActiveTool(name as ToolType), }), }; let activeTool: Tool = tools.select; function setActiveTool(name: ToolType): void { activeTool.onDeactivate?.(); if (name !== 'select') selection = null; activeToolName = name; activeTool = tools[name] || tools.select; activeTool.onActivate?.(); canvas.style.cursor = activeTool.isEditing?.() ? textTool.getEditCursor() : activeTool.cursor; toolbarState.activeToolId = name; syncCompoundFromActiveTool(toolbarState); needsRedraw = true; } // ── UI panels ────────────────────────────────────────────────────── const toolbarState: CanvasToolbarState = createCanvasToolbarState(activeToolName); const shortcutOverlay: ShortcutOverlayState = createShortcutOverlayState(); // ── Tutorial system ──────────────────────────────────────────── initVoice('AIzaSyC4nnnSOWByUN5pdMwmN3Yi5NT5yojDQIo'); /** * Reset floor and camera for a tutorial phase. * segmentIndex = -1 means initial start (clear everything). * For segments that require pre-existing geometry (door/window placement), * we rebuild the room walls so the user has something to place openings on. */ function resetFloorAndCamera(script: TutorialScript, segmentIndex = -1): void { if (script.clearFloor) { pushHistory(); const fresh = createFloorPlan(); applyFloor(fresh); } // Build walls for door/window practice segments if (segmentIndex > 0 && script.segments) { const floor = getFloor(); // Room coordinates from the draw-first-room script const TL = { x: -200, y: -150 }; const TR = { x: 200, y: -150 }; const BR = { x: 200, y: 150 }; const BL = { x: -200, y: 150 }; addWall(floor, TL, TR); addWall(floor, TR, BR); addWall(floor, BR, BL); addWall(floor, BL, TL); } if (script.initialCamera) { camera.state.offsetX = script.initialCamera.panX; camera.state.offsetY = script.initialCamera.panY; camera.setZoom(script.initialCamera.zoom, canvasWidth() / 2, canvasHeight() / 2); } else { camera.state.offsetX = canvasWidth() / 2; camera.state.offsetY = canvasHeight() / 2; camera.setZoom(1, canvasWidth() / 2, canvasHeight() / 2); } setActiveTool('select'); needsRedraw = true; } const tutorialRunner = new TutorialRunner({ dispatch: { canvas, canvasRect: () => canvas.getBoundingClientRect(), }, camera: { worldToScreen: (wx, wy) => camera.worldToScreen(wx, wy), screenToWorld: (sx, sy) => camera.screenToWorld(sx, sy), }, setNeedsRedraw: () => { needsRedraw = true; }, onStart: (script) => resetFloorAndCamera(script, -1), onResetForPractice: (script, segIdx) => resetFloorAndCamera(script, segIdx), onComplete: () => { console.log('[Tutorial] Complete'); }, onStepChange: (idx, step) => { console.log(`[Tutorial] Step ${idx + 1}: ${step.annotation.text}`); }, }); // ── Long-press state for compound buttons (door, dimension) ───── let compoundPressTimer: ReturnType | null = null; let compoundPressButtonId: string | null = null; function clearCompoundPress(): void { if (compoundPressTimer !== null) { clearTimeout(compoundPressTimer); compoundPressTimer = null; } compoundPressButtonId = null; } /** Activate a compound button's current subtype as the active tool. */ function activateCompoundSubtype(buttonId: string): void { const subtype = getCompoundActiveSubtype(toolbarState, buttonId); // Door subtypes route through the door tool; dimension/callout are separate tools if (buttonId === 'door') { const doorTool = tools.door as DoorWindowTool; doorTool.setType(subtype as SymbolType); setActiveTool('door'); } else { setActiveTool(subtype as ToolType); } } /** Called on mousedown over any toolbar button. */ function handleToolbarMouseDown(id: string): void { toolbarState.pressedId = id; needsRedraw = true; // Compound popup item click (popup already open) if (isCompoundPopupItem(id)) { const { buttonId, subtype } = parseCompoundPopupItem(id); setCompoundActiveSubtype(toolbarState, buttonId, subtype); activateCompoundSubtype(buttonId); closeAllCompoundPopups(toolbarState); return; } // Compound button: start long-press timer if (isCompoundButton(id)) { clearCompoundPress(); // If popup is already open, toggle it closed const popup = toolbarState.compounds.get(id); if (popup?.open) { closeAllCompoundPopups(toolbarState); return; } compoundPressButtonId = id; compoundPressTimer = setTimeout(() => { compoundPressTimer = null; closeAllCompoundPopups(toolbarState); openCompoundPopup(toolbarState, id); closeToolbarPopup(); // close any HTML popup needsRedraw = true; }, LONG_PRESS_MS); return; } // All other buttons: immediate click closeAllCompoundPopups(toolbarState); handleToolbarClick(id); } /** Called on mouseup anywhere on canvas. Resolves compound long-press. */ function handleToolbarMouseUp(): void { toolbarState.pressedId = null; needsRedraw = true; if (compoundPressTimer !== null) { // Released before long-press threshold → normal short click const buttonId = compoundPressButtonId!; clearCompoundPress(); activateCompoundSubtype(buttonId); } } function handleToolbarClick(id: string): void { if (isToolButton(id)) { setActiveTool(id as ToolType); closeToolbarPopup(); } else { switch (id) { case 'fullscreen': toggleFullscreen(); break; case 'export': handleExport(); break; case 'save': fetch('/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...project, name: project.name || 'default' }), }).then(() => console.log('[save] Saved')).catch(e => console.error('[save]', e)); break; case 'tutorial': ensureAudioReady(); // Unlock AudioContext from user-gesture call stack tutorialRunner.start(drawFirstRoom); break; } closeToolbarPopup(); } needsRedraw = true; } // ── Popup panel host (floating HTML positioned from canvas toolbar rects) ── const popupHost = document.createElement('div'); popupHost.style.cssText = 'position:fixed;z-index:100;pointer-events:none;inset:0;'; document.getElementById('app')!.appendChild(popupHost); const popupPanels = new Map(); function createPopupContainer(popupId: PopupId): HTMLElement { const el = document.createElement('div'); el.className = 'ftbar-popup'; el.style.display = 'none'; el.style.position = 'absolute'; el.style.pointerEvents = 'auto'; el.setAttribute('data-popup', popupId); popupHost.appendChild(el); popupPanels.set(popupId, el); return el; } function closeToolbarPopup(): void { if (toolbarState.openPopupId) { const el = popupPanels.get(toolbarState.openPopupId); if (el) el.style.display = 'none'; toolbarState.openPopupId = null; needsRedraw = true; } } // Click-outside closes popups (HTML popups and compound popups) document.addEventListener('mousedown', (e) => { if (toolbarState.openPopupId) { const el = popupPanels.get(toolbarState.openPopupId); if (el && !el.contains(e.target as Node) && !hitTestToolbar(toolbarState, e.clientX, e.clientY)) { closeToolbarPopup(); } } // Close compound popups on click outside toolbar area if (!hitTestToolbar(toolbarState, e.clientX, e.clientY)) { closeAllCompoundPopups(toolbarState); needsRedraw = true; } }); // Library popup const libraryContainer = createPopupContainer('library'); const libraryPanel = createLibraryPanel(libraryContainer, (type: SymbolType, category) => { if (category === 'door' || type === 'window') { const doorTool = tools[category === 'door' ? 'door' : 'window'] as DoorWindowTool; doorTool.setType(type); setActiveTool(type === 'window' ? 'window' : 'door'); } else { const furnitureTool = tools.furniture as FurnitureTool; furnitureTool.setType(type); setActiveTool('furniture'); } libraryPanel.setActive(type); }, { ck, fontProvider, typeface, monoTypeface, dimTypeface, paints }); // Properties popup const propertiesContainer = createPopupContainer('properties'); const propertiesPanel = createPropertiesPanel(propertiesContainer); // ── Register solver backends (WASM & HiGHS lazy-init on first use) ── registerSolver(wasmBnbBackend); registerSolver(highsBackend); // ── Diagnostic console API (window.__autoplace) ─────────────────── initAutoplaceDiagnostics({ getFloor, getCk() { return ck; }, setOverlay(overlay) { autoplaceOverlay = overlay; }, requestRedraw() { needsRedraw = true; }, }); // ── Input event wiring (mouse, keyboard, drag-drop, paste) ───────── initInputHandlers({ canvas, camera, history, getFloor, applyFloor, pushHistory, getActiveTool: () => activeTool, getTextTool: () => textTool, setActiveTool, getSelection: () => selection, getSpaceHeld: () => spaceHeld, setSpaceHeld: (v) => { spaceHeld = v; }, setMouseWorld: (p) => { mouseWorld = p; }, setNeedsRedraw: () => { needsRedraw = true; }, getShowPerfOverlay: () => showPerfOverlay, setShowPerfOverlay: (v) => { showPerfOverlay = v; }, getGridSnap: () => gridSnap, setGridSnap: (v) => { gridSnap = v; setGridSnapState(toolbarState, v); }, canvasWidth, canvasHeight, fitViewToContent, getProject: () => project, hitTestToolbar: (sx, sy) => hitTestToolbar(toolbarState, sx, sy), hitTestToolbarButton: (sx, sy) => hitTestToolbarButton(toolbarState, sx, sy), updateToolbarHover: (sx, sy) => updateToolbarHover(toolbarState, sx, sy), onToolbarClick: handleToolbarClick, onToolbarMouseDown: handleToolbarMouseDown, onToolbarMouseUp: handleToolbarMouseUp, toggleShortcutOverlay: () => { toggleShortcutOverlay(shortcutOverlay); }, isShortcutOverlayVisible: () => shortcutOverlay.visible, isTutorialActive: () => tutorialRunner.isActive(), isTutorialCompleted: () => tutorialRunner.state.phase === 'completed', skipTutorialStep: () => tutorialRunner.skip(), completeTutorialPrompt: () => tutorialRunner.completePromptStep(), stopTutorial: () => tutorialRunner.stop(), isTutorialPromptStep: () => { const step = tutorialRunner.getCurrentStep(); return !!step && step.mode === 'prompt' && tutorialRunner.state.phase === 'executing'; }, tryValidatePromptClick: (wx, wy) => tutorialRunner.tryValidatePromptClick(wx, wy), tryValidatePromptKeypress: (key) => tutorialRunner.tryValidatePromptKeypress(key), tryShowMeAgainClick: (sx, sy) => { const rect = tutorialRunner.state.showMeAgainRect; if (!rect) return false; if (sx >= rect.x && sx <= rect.x + rect.w && sy >= rect.y && sy <= rect.y + rect.h) { tutorialRunner.replaySegmentDemo(); return true; } return false; }, toggleFullscreen, }); // ── Helpers ──────────────────────────────────────────────────────── function toggleFullscreen(): void { if (document.fullscreenElement) { document.exitFullscreen(); } else { document.documentElement.requestFullscreen(); } } function fitViewToContent(): void { const floor = getFloor(); if (floor.walls.length === 0 && floor.symbols.length === 0 && floor.artboards.length === 0) { camera.reset(canvasWidth(), canvasHeight()); needsRedraw = true; return; } let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const wall of floor.walls) { for (const p of [wall.start, wall.end]) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } } for (const sym of floor.symbols) { minX = Math.min(minX, sym.position.x - sym.width / 2); minY = Math.min(minY, sym.position.y - sym.height / 2); maxX = Math.max(maxX, sym.position.x + sym.width / 2); maxY = Math.max(maxY, sym.position.y + sym.height / 2); } for (const ab of floor.artboards) { minX = Math.min(minX, ab.position.x); minY = Math.min(minY, ab.position.y); maxX = Math.max(maxX, ab.position.x + ab.widthCm); maxY = Math.max(maxY, ab.position.y + ab.heightCm); } camera.fitToRect( { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, canvasWidth(), canvasHeight() ); needsRedraw = true; } function handleExport(): void { const floor = getFloor(); const deps = { ck, paints, fontProvider, typeface, monoTypeface, dimTypeface }; const hasSelection = !!selection; showExportDialog(floor.artboards, hasSelection, (opts: ExportOptions) => { // Selection export if (opts.exportSelection && selection) { exportSelectionPNG(deps, floor, selection, project.unitSystem, opts.dpi, opts.background === 'transparent'); return; } const selectedAbs = floor.artboards.filter(a => opts.artboardIds.includes(a.id)); // PNG export if (selectedAbs.length === 0) { exportFullScenePNG(deps, floor, project.unitSystem, project.name, opts.dpi); return; } for (const ab of selectedAbs) { const bytes = exportArtboardPNG( deps, floor, ab, project.unitSystem, opts.dpi, opts.background === 'transparent', ); if (bytes) { const safeName = ab.name.replace(/[^a-zA-Z0-9_-]/g, '_'); downloadBlob(bytes.slice(), `${safeName}.png`); } } }); } // ── Render loop (CanvasKit surface.requestAnimationFrame) ───────── function drawFrame() { if (needsRedraw) { needsRedraw = false; const w = canvasWidth(); const h = canvasHeight(); const skCanvas = surface.getCanvas(); const dpr = window.devicePixelRatio || 1; // DPR transform — maps CSS coordinates to physical surface pixels skCanvas.save(); skCanvas.concat(Float32Array.of(dpr, 0, 0, 0, dpr, 0, 0, 0, 1)); const dc: DrawContext = { ck, canvas: skCanvas, pool, paints, fontProvider, typeface, monoTypeface, dimTypeface, zoom: camera.state.zoom, }; // Show wall dimensions only when a single wall is selected (not multi-wall marquee) const showWallLengths = selection?.type === 'wall'; const selectedWallId = selection?.type === 'wall' ? selection.id : null; const perf: PerfTimings | undefined = showPerfOverlay ? { grid: 0, walls: 0, symbols: 0, texts: 0, dims: 0, wlen: 0, total: 0 } : undefined; const frameStart = showPerfOverlay ? performance.now() : 0; // Inject ghost wall for preview (wall tool creates proper miter joins via wall graph) const ghostWall = wallTool.getPreviewWall(); const floor = getFloor(); if (ghostWall) floor.walls.push(ghostWall); lastWallGraph = render({ dc, camera, width: w, height: h, walls: floor.walls, symbols: floor.symbols, dimensions: floor.dimensions, texts: floor.texts, selection, unit: project.unitSystem, editingTextId: textTool.isEditing() ? textTool.getEditingTextId() : null, hideTextHandles: true, hoveredTextId: selectTool.hoveredTextId, showWallLengths, selectedWallId, hoveredWallId: activeToolName === 'select' ? selectTool.hoveredWallId : null, auxDimWallIds: selectTool.dragAuxDimWallIds, isWallDragging: selectTool.isWallDragging, editingDimId: selectTool.editingDimId, perfTimings: perf, artboards: floor.artboards, hoveredSymbolId: activeToolName === 'select' ? selectTool.hoveredSymbolId : null, hoveredSymbolProx: activeToolName === 'select' ? selectTool.hoveredSymbolProx : 0, isWallLengthEditing: selectTool.isWallLengthEditing, isOpeningWidthEditing: selectTool.isOpeningWidthEditing || selectTool.isSymbolDragging, images: floor.images, imageCache, regions: floor.regions, hoveredRegionId: activeToolName === 'select' ? selectTool.hoveredRegionId : null, hoveredRegionEdge: activeToolName === 'select' ? selectTool.hoveredRegionEdge : null, isRegionEdgeEditing: selectTool.isRegionEdgeEditing, editingRegionNameId: selectTool.editingRegionNameId, isRegionEdgeDragging: selectTool.isRegionEdgeDragging, callouts: floor.callouts, hoveredCalloutId: activeToolName === 'select' ? selectTool.hoveredCalloutId : null, }); // Remove ghost wall after render if (ghostWall) floor.walls.pop(); // Tool overlay (world coords, camera transform applied) skCanvas.save(); skCanvas.concat(camera.getMatrix()); activeTool.renderOverlay(dc, camera.state.zoom); // Render text editing overlay when editing inline from another tool if (activeTool !== textTool && textTool.isEditing()) { textTool.renderOverlay(dc, camera.state.zoom); } // Autoplace overlay (rooms, candidates, chosen desks) if (autoplaceOverlay) { renderAutoplaceOverlay(dc, autoplaceOverlay, camera.state.zoom); } // Debug rects from __showHandles() // Debug overlays from __showHandles() const debugRects = (window as any).__debugRects as Array<{ points: Array<{x: number; y: number}>; color: string }> | undefined; const debugCircles = (window as any).__debugCircles as Array<{ cx: number; cy: number; r: number; color: string }> | undefined; if (debugRects) { for (const dr of debugRects) { const paint = pool.track(new ck.Paint()); paint.setStyle(ck.PaintStyle.Stroke); paint.setColor(ck.parseColorString(dr.color)); paint.setStrokeWidth(2 / camera.state.zoom); paint.setAntiAlias(true); const pb = new ck.PathBuilder(); pb.moveTo(dr.points[0].x, dr.points[0].y); for (let i = 1; i < dr.points.length; i++) pb.lineTo(dr.points[i].x, dr.points[i].y); pb.close(); const path = pool.track(pb.detach()); pb.delete(); skCanvas.drawPath(path, paint); paint.setStyle(ck.PaintStyle.Fill); paint.setColor(dr.color === 'red' ? ck.Color4f(1, 0, 0, 0.15) : ck.Color4f(0, 1, 0, 0.15)); skCanvas.drawPath(path, paint); } needsRedraw = true; } if (debugCircles) { for (const dc2 of debugCircles) { const paint = pool.track(new ck.Paint()); paint.setStyle(ck.PaintStyle.Stroke); paint.setColor(ck.parseColorString(dc2.color)); paint.setStrokeWidth(2 / camera.state.zoom); paint.setAntiAlias(true); skCanvas.drawCircle(dc2.cx, dc2.cy, dc2.r, paint); paint.setStyle(ck.PaintStyle.Fill); paint.setColor(dc2.color === 'red' ? ck.Color4f(1, 0, 0, 0.15) : ck.Color4f(0, 1, 0, 0.15)); skCanvas.drawCircle(dc2.cx, dc2.cy, dc2.r, paint); } needsRedraw = true; } skCanvas.restore(); // Ink bounds diagnostic overlay — draw in text-local coords to match renderer exactly if (showInkOverlay) { const inkPaint = pool.track(new ck.Paint()); inkPaint.setStyle(ck.PaintStyle.Stroke); inkPaint.setAntiAlias(true); for (const t of getFloor().texts) { const fontWeight = t.fontWeight === 'bold' ? 700 : 400; const para = pool.track( makeTextParagraph(dc, t.text, t.fontSize, Float32Array.of(0, 0, 0, 1), fontWeight, t.fontStyle, 'center') ); const ink = measureInkBounds(dc, typeface, para); const w = ink.paraWidth; const h = ink.paraHeight; const es = camera.state.zoom * t.scale; // Also compute CBox-only bounds for comparison const lines = para.getShapedLines(); let cboxL = Infinity, cboxR = -Infinity, cboxT = Infinity, cboxB = -Infinity; for (const line of lines) { for (const run of line.runs) { const font = new ck.Font(typeface, run.size); const bounds = font.getGlyphBounds(run.glyphs); for (let i = 0; i < run.glyphs.length; i++) { const px = run.positions[i * 2]; const py = run.positions[i * 2 + 1]; cboxL = Math.min(cboxL, px + bounds[i * 4]); cboxT = Math.min(cboxT, py + bounds[i * 4 + 1]); cboxR = Math.max(cboxR, px + bounds[i * 4 + 2]); cboxB = Math.max(cboxB, py + bounds[i * 4 + 3]); } font.delete(); } } skCanvas.save(); skCanvas.concat(camera.getMatrix()); skCanvas.translate(t.position.x, t.position.y); skCanvas.scale(t.scale, t.scale); inkPaint.setStrokeWidth(1 / es); // Green = advance-based box (getLongestLine × getHeight) inkPaint.setColor(ck.Color4f(0, 0.8, 0, 0.4)); skCanvas.drawRect(ck.LTRBRect(-w / 2, -h / 2, w / 2, h / 2), inkPaint); // Red = CBox (conservative glyph outline control-point bounds) inkPaint.setColor(ck.Color4f(1, 0, 0, 0.6)); skCanvas.drawRect(ck.LTRBRect( -w / 2 + cboxL, -h / 2 + cboxT, -w / 2 + cboxR, -h / 2 + cboxB ), inkPaint); // Blue = tight intercept bounds (actual outline curves) inkPaint.setColor(ck.Color4f(0, 0.4, 1, 0.9)); inkPaint.setStrokeWidth(1.5 / es); skCanvas.drawRect(ck.LTRBRect( -w / 2 + ink.left, -h / 2 + ink.top, -w / 2 + ink.right, -h / 2 + ink.bottom ), inkPaint); skCanvas.restore(); } needsRedraw = true; } // Opening bounds diagnostic overlay (world space) if (showOpeningBounds) { const obFloor = getFloor(); const obPaint = pool.track(new ck.Paint()); obPaint.setAntiAlias(true); const sw = 1 / camera.state.zoom; // 1px screen-space stroke skCanvas.save(); skCanvas.concat(camera.getMatrix()); for (const sym of obFloor.symbols) { if (!sym.wallIdBefore || !sym.wallIdAfter || !sym.wallId) continue; const wb = obFloor.walls.find(ww => ww.id === sym.wallIdBefore); const gw = obFloor.walls.find(ww => ww.id === sym.wallId); const wa = obFloor.walls.find(ww => ww.id === sym.wallIdAfter); if (!wb || !gw || !wa) continue; const thick = wb.thickness / 2; const dir = { x: wa.end.x - wb.start.x, y: wa.end.y - wb.start.y }; const len = Math.hypot(dir.x, dir.y); if (len < 1) continue; const dx = dir.x / len, dy = dir.y / len; const nx = -dy, ny = dx; // perpendicular // Helper: rect along wall axis from p1 to p2, expanded by wall half-thickness const drawSegRect = (p1: Point, p2: Point, color: Float32Array, fill: boolean) => { const b = new ck.PathBuilder(); b.moveTo(p1.x + nx * thick, p1.y + ny * thick); b.lineTo(p2.x + nx * thick, p2.y + ny * thick); b.lineTo(p2.x - nx * thick, p2.y - ny * thick); b.lineTo(p1.x - nx * thick, p1.y - ny * thick); b.close(); const path = pool.track(b.detach()); obPaint.setColor(color); if (fill) { obPaint.setStyle(ck.PaintStyle.Fill); skCanvas.drawPath(path, obPaint); } obPaint.setStyle(ck.PaintStyle.Stroke); obPaint.setStrokeWidth(sw); skCanvas.drawPath(path, obPaint); }; // wallBefore — blue drawSegRect(wb.start, wb.end, ck.Color4f(0.2, 0.4, 1, 0.15), true); drawSegRect(wb.start, wb.end, ck.Color4f(0.2, 0.4, 1, 0.6), false); // gap — red drawSegRect(gw.start, gw.end, ck.Color4f(1, 0.2, 0.2, 0.15), true); drawSegRect(gw.start, gw.end, ck.Color4f(1, 0.2, 0.2, 0.6), false); // wallAfter — green drawSegRect(wa.start, wa.end, ck.Color4f(0.2, 0.8, 0.2, 0.15), true); drawSegRect(wa.start, wa.end, ck.Color4f(0.2, 0.8, 0.2, 0.6), false); // Find perpendicular wall overhang at each chain endpoint (miter compensation) let startOH = 0, endOH = 0; for (const ow of obFloor.walls) { if (ow.openingId) continue; if (ow.id === sym.wallIdBefore || ow.id === sym.wallId || ow.id === sym.wallIdAfter) continue; const owDir = { x: ow.end.x - ow.start.x, y: ow.end.y - ow.start.y }; const owLen = Math.hypot(owDir.x, owDir.y); if (owLen < 1) continue; const owDn = { x: owDir.x / owLen, y: owDir.y / owLen }; if (Math.abs(dx * owDn.x + dy * owDn.y) > 0.3) continue; // must be ~perpendicular for (const pt of [ow.start, ow.end]) { if (Math.hypot(pt.x - wb.start.x, pt.y - wb.start.y) < 4 + thick) { startOH = Math.max(startOH, ow.thickness / 2); } if (Math.hypot(pt.x - wa.end.x, pt.y - wa.end.y) < 4 + thick) { endOH = Math.max(endOH, ow.thickness / 2); } } } // Drag limit zones — orange at each end (5cm from visual corner) const OPENING_MIN_GAP = 5; // Orange zone starts at visual corner (centerline - overhang), ends 20cm inward const limitLeftStart = { x: wb.start.x - dx * startOH, y: wb.start.y - dy * startOH, }; const limitLeft = { x: wb.start.x + dx * OPENING_MIN_GAP, y: wb.start.y + dy * OPENING_MIN_GAP, }; drawSegRect(limitLeftStart, limitLeft, ck.Color4f(1, 0.6, 0, 0.25), true); drawSegRect(limitLeftStart, limitLeft, ck.Color4f(1, 0.6, 0, 0.7), false); const limitRightEnd = { x: wa.end.x + dx * endOH, y: wa.end.y + dy * endOH, }; const limitRight = { x: wa.end.x - dx * OPENING_MIN_GAP, y: wa.end.y - dy * OPENING_MIN_GAP, }; drawSegRect(limitRight, limitRightEnd, ck.Color4f(1, 0.6, 0, 0.25), true); drawSegRect(limitRight, limitRightEnd, ck.Color4f(1, 0.6, 0, 0.7), false); // Label: lengths const fontSize = 10 / camera.state.zoom; const labelPaint = pool.track(new ck.Paint()); labelPaint.setColor(ck.Color4f(0, 0, 0, 0.8)); const wbLen = Math.hypot(wb.end.x - wb.start.x, wb.end.y - wb.start.y); const waLen = Math.hypot(wa.end.x - wa.start.x, wa.end.y - wa.start.y); const midWb = { x: (wb.start.x + wb.end.x) / 2 + nx * (thick + fontSize), y: (wb.start.y + wb.end.y) / 2 + ny * (thick + fontSize) }; const midWa = { x: (wa.start.x + wa.end.x) / 2 + nx * (thick + fontSize), y: (wa.start.y + wa.end.y) / 2 + ny * (thick + fontSize) }; const wbPara = pool.track(makeTextParagraph(dc, `${wbLen.toFixed(0)}cm`, fontSize * camera.state.zoom, ck.Color4f(0.2, 0.4, 1, 1), 700, 'normal', 'left')); skCanvas.save(); skCanvas.translate(midWb.x, midWb.y); skCanvas.scale(1 / camera.state.zoom, 1 / camera.state.zoom); skCanvas.drawParagraph(wbPara, 0, 0); skCanvas.restore(); const waPara = pool.track(makeTextParagraph(dc, `${waLen.toFixed(0)}cm`, fontSize * camera.state.zoom, ck.Color4f(0.2, 0.8, 0.2, 1), 700, 'normal', 'left')); skCanvas.save(); skCanvas.translate(midWa.x, midWa.y); skCanvas.scale(1 / camera.state.zoom, 1 / camera.state.zoom); skCanvas.drawParagraph(waPara, 0, 0); skCanvas.restore(); } skCanvas.restore(); needsRedraw = true; } // Marquee selection rectangle (screen space — CSS coords, DPR already applied) const marqueeRect = selectTool.getMarqueeScreenRect(); if (marqueeRect) { const marqueeStroke = pool.track(new ck.Paint()); marqueeStroke.setStyle(ck.PaintStyle.Stroke); marqueeStroke.setColor(ck.parseColorString('#2563eb')); marqueeStroke.setStrokeWidth(1); marqueeStroke.setAntiAlias(true); const dashEffect = pool.track(ck.PathEffect.MakeDash([6, 4], 0)); marqueeStroke.setPathEffect(dashEffect); const rect = ck.XYWHRect(marqueeRect.x, marqueeRect.y, marqueeRect.width, marqueeRect.height); skCanvas.drawRect(rect, marqueeStroke); const marqueeFill = pool.track(new ck.Paint()); marqueeFill.setStyle(ck.PaintStyle.Fill); marqueeFill.setColor(ck.Color4f(37 / 255, 99 / 255, 235 / 255, 0.08)); marqueeFill.setAntiAlias(true); skCanvas.drawRect(rect, marqueeFill); needsRedraw = true; // Keep redrawing while marquee is active } // Canvas toolbar (screen space, constant size) renderCanvasToolbar(dc, toolbarState, w, h); if (isToolbarAnimating(toolbarState)) needsRedraw = true; // Shortcut overlay (screen space, full viewport) renderShortcutOverlay(dc, shortcutOverlay, w, h); // Tutorial overlay (screen space — cursor, spotlights, instruction panel) if (tutorialRunner.isActive() || tutorialRunner.state.phase === 'completed') { const tutAnimating = renderTutorialOverlay(dc, tutorialRunner, w, h); if (tutAnimating) needsRedraw = true; } // Performance overlay (screen space, drawn before flush) if (perf) { perf.total = performance.now() - frameStart; // Draw overlay text in screen space (DPR already applied) const lines = [ `grid ${perf.grid.toFixed(2)}ms`, `walls ${perf.walls.toFixed(2)}ms`, `syms ${perf.symbols.toFixed(2)}ms`, `texts ${perf.texts.toFixed(2)}ms`, `dims ${perf.dims.toFixed(2)}ms`, `wlen ${perf.wlen.toFixed(2)}ms`, `flush ${lastFlushMs.toFixed(2)}ms`, `total ${perf.total.toFixed(2)}ms`, ]; const fontSize = 11; const lineH = 15; const padX = 8; const padY = 6; const boxW = 140; const boxH = lines.length * lineH + padY * 2; const boxX = w - boxW - 10; const boxY = 10; // Semi-transparent background const bgPaint = pool.track(new ck.Paint()); bgPaint.setStyle(ck.PaintStyle.Fill); bgPaint.setColor(ck.Color4f(0, 0, 0, 0.75)); skCanvas.drawRect(ck.XYWHRect(boxX, boxY, boxW, boxH), bgPaint); // Text lines const textPaint = pool.track(new ck.Paint()); textPaint.setStyle(ck.PaintStyle.Fill); textPaint.setColor(ck.Color4f(0, 1, 0, 1)); textPaint.setAntiAlias(true); const font = new ck.Font(typeface, fontSize); for (let i = 0; i < lines.length; i++) { skCanvas.drawText(lines[i], boxX + padX, boxY + padY + (i + 1) * lineH - 2, textPaint, font); } font.delete(); needsRedraw = true; // Keep redrawing to update timings } skCanvas.restore(); // DPR transform // Flush per-frame resources pool.flush(); const flushStart = performance.now(); surface.flush(); if (showPerfOverlay) lastFlushMs = performance.now() - flushStart; } requestAnimationFrame(drawFrame); } // ── Init ─────────────────────────────────────────────────────────── initTestExports({ ck, typeface, monoTypeface, dimTypeface, fontProvider, canvas, camera, project, getFloor, getSelection: () => selection, setNeedsRedraw: () => { needsRedraw = true; }, getShowInkOverlay: () => showInkOverlay, setShowInkOverlay: (v: boolean) => { showInkOverlay = v; }, getShowOpeningBounds: () => showOpeningBounds, setShowOpeningBounds: (v: boolean) => { showOpeningBounds = v; }, getActiveToolName: () => activeToolName, getLastWallGraph: () => lastWallGraph, getMouseWorld: () => mouseWorld, selectTool, wallTool, }); // ── Tutorial API (available in both dev and prod) ────────────────── const w = window as any; w.__startTutorial = (script: TutorialScript) => { // Unlock AudioContext from user-gesture call stack (required by browsers) ensureAudioReady(); tutorialRunner.start(script); console.log(`[Tutorial] Started: "${script.title}" (${script.steps.length} steps)`); }; w.__stopTutorial = () => tutorialRunner.stop(); w.__setVoiceSpeed = setVoiceSpeed; w.__tutorialRunner = tutorialRunner; // Built-in tutorial scripts (accessible from console or tests) w.__tutorials = { drawFirstRoom }; window.addEventListener('resize', resizeCanvas); resizeCanvas(); camera.reset(canvasWidth(), canvasHeight()); requestAnimationFrame(drawFrame); } // Start the app main().catch(err => { console.error('Failed to initialize CanvasKit:', err); stopPreloader(); const loadingEl = document.getElementById('loading'); if (loadingEl) { loadingEl.style.display = 'flex'; loadingEl.textContent = 'Failed to load rendering engine. Please refresh.'; } });