├── public ├── icon.png ├── splash.jpg ├── menu-logo.png ├── pix3-logo.png ├── splash-logo.png └── vite.svg ├── src ├── templates │ ├── Duck.glb │ ├── pix3-logo.png │ ├── test_model.glb │ └── startup-scene.pix3scene ├── fw │ ├── layout-component-base.ts │ ├── index.ts │ ├── from-query.ts │ ├── property-schema-utils.ts │ ├── component-base.ts │ ├── di.ts │ └── property-schema.ts ├── main.ts ├── nodes │ ├── 3D │ │ ├── MeshInstance.ts │ │ ├── DirectionalLightNode.ts │ │ ├── Camera3D.ts │ │ └── GeometryMesh.ts │ ├── 2D │ │ ├── Sprite2D.ts │ │ └── Group2D.ts │ ├── Node2D.ts │ ├── NodeBase.ts │ └── Node3D.ts ├── ui │ ├── shared │ │ ├── pix3-toolbar.ts.css │ │ ├── pix3-toolbar-button.ts.css │ │ ├── pix3-confirm-dialog.ts.css │ │ ├── pix3-toolbar.ts │ │ ├── pix3-confirm-dialog.ts │ │ ├── pix3-main-menu.ts.css │ │ ├── pix3-panel.ts.css │ │ ├── pix3-dropdown.ts.css │ │ ├── pix3-dropdown-button.ts.css │ │ ├── pix3-toolbar-button.ts │ │ └── pix3-panel.ts │ ├── scene-tree │ │ ├── scene-tree-panel.ts.css │ │ ├── node-visuals.helper.ts │ │ └── scene-tree-node.ts.css │ ├── pix3-editor-shell.ts.css │ ├── assets-browser │ │ ├── asset-browser-panel.ts.css │ │ └── asset-tree.ts.css │ ├── viewport │ │ ├── transform-toolbar.ts │ │ └── viewport-panel.ts.css │ └── logs-view │ │ ├── logs-panel.ts.css │ │ └── logs-panel.ts ├── services │ ├── index.ts │ ├── template-data.ts │ ├── TemplateService.ts │ ├── AssetFileActivationService.ts │ ├── ViewportRenderService.spec.ts │ ├── CommandDispatcher.ts │ ├── DialogService.ts │ └── LoggingService.ts ├── core │ ├── BulkOperation.ts │ ├── Operation.ts │ ├── SceneManager.ts │ └── AssetLoader.ts ├── state │ └── index.ts ├── features │ ├── history │ │ ├── RedoCommand.ts │ │ └── UndoCommand.ts │ ├── scene │ │ ├── ReparentNodeCommand.ts │ │ ├── AddModelCommand.ts │ │ ├── ReloadSceneCommand.ts │ │ ├── UpdateGroup2DSizeCommand.ts │ │ ├── CreateBoxCommand.ts │ │ ├── CreateGroup2DCommand.ts │ │ ├── CreateCamera3DCommand.ts │ │ ├── CreateSprite2DCommand.ts │ │ ├── CreateMeshInstanceCommand.ts │ │ ├── CreateDirectionalLightCommand.ts │ │ ├── UpdateGroup2DSizeOperation.ts │ │ ├── ReloadSceneOperation.ts │ │ └── CreateBoxOperation.ts │ ├── selection │ │ ├── SelectObjectCommand.ts │ │ └── SelectObjectOperation.ts │ └── properties │ │ ├── UpdateObjectPropertyCommand.ts │ │ └── UpdateObjectPropertyOperation.ts ├── index.css └── sw.ts ├── design_assets └── logo-sketch.psd ├── .prettierignore ├── .prettierrc.json ├── .gitignore ├── index.html ├── vite.config.ts ├── vitest.config.ts ├── .github └── workflows │ └── ci.yml.disabled ├── tsconfig.json ├── package.json ├── test_assets └── test_project_1 │ ├── New Scene.pix3scene │ └── group2d-test.pix3scene ├── eslint.config.js └── docs └── property-schema-quick-reference.md /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/pix3/main/public/icon.png -------------------------------------------------------------------------------- /public/splash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/pix3/main/public/splash.jpg -------------------------------------------------------------------------------- /public/menu-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/pix3/main/public/menu-logo.png -------------------------------------------------------------------------------- /public/pix3-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/pix3/main/public/pix3-logo.png -------------------------------------------------------------------------------- /public/splash-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/pix3/main/public/splash-logo.png -------------------------------------------------------------------------------- /src/templates/Duck.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/pix3/main/src/templates/Duck.glb -------------------------------------------------------------------------------- /design_assets/logo-sketch.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/pix3/main/design_assets/logo-sketch.psd -------------------------------------------------------------------------------- /src/templates/pix3-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/pix3/main/src/templates/pix3-logo.png -------------------------------------------------------------------------------- /src/templates/test_model.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/pix3/main/src/templates/test_model.glb -------------------------------------------------------------------------------- /src/fw/layout-component-base.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase } from './component-base'; 2 | 3 | export class LayoutComponentBase extends ComponentBase {} 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | .env.local 5 | .env.development.local 6 | .env.test.local 7 | .env.production.local 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | *.log -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "endOfLine": "lf", 9 | "arrowParens": "avoid", 10 | "bracketSpacing": true, 11 | "bracketSameLine": false, 12 | "quoteProps": "as-needed" 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .chrome_debug_profile -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pix3 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import 'golden-layout/dist/css/goldenlayout-base.css'; 3 | import 'golden-layout/dist/css/themes/goldenlayout-dark-theme.css'; 4 | 5 | import './index.css'; 6 | 7 | import './ui/scene-tree/scene-tree-panel'; 8 | import './ui/viewport/viewport-panel'; 9 | import './ui/object-inspector/inspector-panel'; 10 | import './ui/assets-browser/asset-browser-panel'; 11 | import './ui/pix3-editor-shell'; 12 | -------------------------------------------------------------------------------- /src/nodes/3D/MeshInstance.ts: -------------------------------------------------------------------------------- 1 | import { Node3D, type Node3DProps } from '@/nodes/Node3D'; 2 | import { AnimationClip } from 'three'; 3 | 4 | export interface MeshInstanceProps extends Omit { 5 | src?: string | null; // res:// or templ:// path to .glb/.gltf 6 | } 7 | 8 | export class MeshInstance extends Node3D { 9 | readonly src: string | null; 10 | animations: AnimationClip[] = []; 11 | 12 | constructor(props: MeshInstanceProps) { 13 | super(props, 'MeshInstance'); 14 | this.src = props.src ?? null; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/fw/index.ts: -------------------------------------------------------------------------------- 1 | import { customElement, property, state } from 'lit/decorators.js'; 2 | import { inject } from './di'; 3 | import { css, html, unsafeCSS } from 'lit'; 4 | 5 | import { fromQuery } from './from-query'; 6 | 7 | import { subscribe } from 'valtio/vanilla'; 8 | 9 | export * from './component-base'; 10 | export * from './di'; 11 | export * from './layout-component-base'; 12 | export * from './property-schema'; 13 | export * from './property-schema-utils'; 14 | 15 | export { html, css, unsafeCSS, customElement, property, state, inject, fromQuery, subscribe }; 16 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-toolbar.ts.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | } 4 | 5 | .toolbar { 6 | display: flex; 7 | align-items: start; 8 | inline-size: 100%; 9 | padding: 6px 8px; 10 | color: var(--pix3-toolbar-foreground, rgba(245, 247, 250, 0.92)); 11 | border-bottom: 1px solid rgba(255, 255, 255, 0.08); 12 | } 13 | 14 | :host([dense]) .toolbar { 15 | padding-block: 0.5rem; 16 | } 17 | 18 | .toolbar__section { 19 | display: flex; 20 | align-items: center; 21 | gap: 0.5rem; 22 | } 23 | 24 | .toolbar__section--start { 25 | min-width: 0; 26 | } 27 | 28 | .toolbar__section--content { 29 | flex: 1; 30 | min-width: 0; 31 | } 32 | 33 | .toolbar__section--actions { 34 | gap: 0.25rem; 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/scene-tree/scene-tree-panel.ts.css: -------------------------------------------------------------------------------- 1 | pix3-scene-tree-panel { 2 | display: block; 3 | height: 100%; 4 | } 5 | 6 | pix3-panel { 7 | height: 100%; 8 | } 9 | 10 | .tree-container { 11 | display: flex; 12 | flex-direction: column; 13 | min-height: 100%; 14 | font-size: 0.75rem; /* More compact, matches asset tree */ 15 | color: rgba(245, 247, 250, 0.88); 16 | overflow: auto; 17 | } 18 | 19 | .tree-root, 20 | .tree-children { 21 | list-style: none; 22 | margin: 0; 23 | } 24 | 25 | .tree-root { 26 | display: flex; 27 | flex-direction: column; 28 | gap: 0.08rem; 29 | padding: 0; 30 | } 31 | 32 | .panel-placeholder { 33 | margin: 0; 34 | color: rgba(245, 247, 250, 0.58); 35 | font-style: italic; 36 | } 37 | -------------------------------------------------------------------------------- /src/fw/from-query.ts: -------------------------------------------------------------------------------- 1 | // Decorator to inject value from hash-based query string 2 | export function fromQuery(paramName: string) { 3 | return function (target: object, propertyKey: string) { 4 | const getter = function () { 5 | const hash = window.location.hash; 6 | if (hash) { 7 | const queryIndex = hash.indexOf('?'); 8 | if (queryIndex !== -1) { 9 | const query = hash.substring(queryIndex + 1); 10 | const params = new URLSearchParams(query); 11 | return params.get(paramName); 12 | } 13 | } 14 | return null; 15 | }; 16 | Object.defineProperty(target, propertyKey, { 17 | get: getter, 18 | enumerable: true, 19 | configurable: true, 20 | }); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/nodes/3D/DirectionalLightNode.ts: -------------------------------------------------------------------------------- 1 | import { Color, DirectionalLight } from 'three'; 2 | import { Node3D, type Node3DProps } from '@/nodes/Node3D'; 3 | 4 | export interface DirectionalLightNodeProps extends Omit { 5 | color?: string; 6 | intensity?: number; 7 | } 8 | 9 | export class DirectionalLightNode extends Node3D { 10 | readonly light: DirectionalLight; 11 | 12 | constructor(props: DirectionalLightNodeProps) { 13 | super(props, 'DirectionalLight'); 14 | const color = new Color(props.color ?? '#ffffff').convertSRGBToLinear(); 15 | const intensity = typeof props.intensity === 'number' ? props.intensity : 1; 16 | this.light = new DirectionalLight(color, intensity); 17 | this.light.castShadow = true; 18 | this.add(this.light); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { resolve } from 'path'; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | '@': resolve(__dirname, 'src'), 8 | '@/components': resolve(__dirname, 'src/components'), 9 | '@/core': resolve(__dirname, 'src/core'), 10 | '@/plugins': resolve(__dirname, 'src/plugins'), 11 | '@/rendering': resolve(__dirname, 'src/rendering'), 12 | '@/services': resolve(__dirname, 'src/services'), 13 | '@/state': resolve(__dirname, 'src/state'), 14 | '@/styles': resolve(__dirname, 'src/styles'), 15 | '@/fw': resolve(__dirname, 'src/fw'), 16 | }, 17 | }, 18 | build: { 19 | rollupOptions: { 20 | output: { 21 | manualChunks: undefined, 22 | }, 23 | }, 24 | }, 25 | }); -------------------------------------------------------------------------------- /src/nodes/3D/Camera3D.ts: -------------------------------------------------------------------------------- 1 | import { PerspectiveCamera, OrthographicCamera, Camera } from 'three'; 2 | import { Node3D, type Node3DProps } from '@/nodes/Node3D'; 3 | 4 | export interface Camera3DProps extends Omit { 5 | projection?: 'perspective' | 'orthographic'; 6 | fov?: number; 7 | near?: number; 8 | far?: number; 9 | } 10 | 11 | export class Camera3D extends Node3D { 12 | readonly camera: Camera; 13 | 14 | constructor(props: Camera3DProps) { 15 | super(props, 'Camera3D'); 16 | 17 | const projection = props.projection ?? 'perspective'; 18 | const near = props.near ?? 0.1; 19 | const far = props.far ?? 1000; 20 | 21 | if (projection === 'perspective') { 22 | const fov = props.fov ?? 60; 23 | this.camera = new PerspectiveCamera(fov, 1, near, far); // aspect will be set by viewport 24 | } else { 25 | // For orthographic, need left, right, top, bottom – for now, default 26 | this.camera = new OrthographicCamera(-1, 1, 1, -1, near, far); 27 | } 28 | 29 | this.add(this.camera); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import { resolve } from 'path'; 3 | 4 | // Exclude known outdated / failing specs so the test suite can run cleanly. 5 | export default defineConfig({ 6 | test: { 7 | environment: 'happy-dom', 8 | include: ['src/**/*.spec.ts'], 9 | exclude: [ 10 | 'src/core/commands/SelectObjectCommand.spec.ts', 11 | 'src/core/rendering/ViewportRendererService.spec.ts', 12 | 'src/core/commands/LoadSceneCommand.spec.ts', 13 | ], 14 | }, 15 | resolve: { 16 | alias: { 17 | '@': resolve(__dirname, 'src'), 18 | '@/components': resolve(__dirname, 'src/components'), 19 | '@/core': resolve(__dirname, 'src/core'), 20 | '@/plugins': resolve(__dirname, 'src/plugins'), 21 | '@/rendering': resolve(__dirname, 'src/rendering'), 22 | '@/services': resolve(__dirname, 'src/services'), 23 | '@/state': resolve(__dirname, 'src/state'), 24 | '@/styles': resolve(__dirname, 'src/styles'), 25 | '@/fw': resolve(__dirname, 'src/fw'), 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml.disabled: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | jobs: 10 | lint-and-build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x] 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Type check 31 | run: npm run type-check 32 | 33 | - name: Lint 34 | run: npm run lint 35 | 36 | - name: Format check 37 | run: npm run format:check 38 | 39 | - name: Run tests 40 | run: npm run test 41 | 42 | - name: Build 43 | run: npm run build 44 | 45 | - name: Upload build artifacts 46 | uses: actions/upload-artifact@v4 47 | if: matrix.node-version == '20.x' 48 | with: 49 | name: dist 50 | path: dist/ -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | FileSystemAPIService, 3 | type FileSystemAPIErrorCode, 4 | FileSystemAPIError, 5 | type FileDescriptor, 6 | type FileSystemAPIServiceOptions, 7 | type ReadSceneResult, 8 | resolveFileSystemAPIService, 9 | } from './FileSystemAPIService'; 10 | export { 11 | TemplateService as BaseTemplateService, 12 | DEFAULT_TEMPLATE_SCENE_ID, 13 | type TemplateScheme, 14 | } from './TemplateService'; 15 | export { ResourceManager, type ReadResourceOptions } from './ResourceManager'; 16 | export { FocusRingService, type FocusRingServiceOptions } from './FocusRingService'; 17 | export { ProjectService, resolveProjectService } from './ProjectService'; 18 | export { AssetFileActivationService, type AssetActivation } from './AssetFileActivationService'; 19 | export { CommandDispatcher, resolveCommandDispatcher } from './CommandDispatcher'; 20 | export { LoggingService, type LogLevel, type LogEntry, type LogListener } from './LoggingService'; 21 | export { CommandRegistry, type CommandMenuItem, type MenuSection } from './CommandRegistry'; 22 | export { FileWatchService } from './FileWatchService'; 23 | export { 24 | DialogService, 25 | type DialogOptions, 26 | type DialogInstance, 27 | resolveDialogService, 28 | } from './DialogService'; 29 | -------------------------------------------------------------------------------- /src/core/BulkOperation.ts: -------------------------------------------------------------------------------- 1 | import type { OperationCommit } from './Operation'; 2 | 3 | export class BulkOperationBuilder { 4 | private commits: OperationCommit[] = []; 5 | 6 | get size(): number { 7 | return this.commits.length; 8 | } 9 | 10 | isEmpty(): boolean { 11 | return this.commits.length === 0; 12 | } 13 | 14 | add(commit: OperationCommit): void { 15 | this.commits.push(commit); 16 | } 17 | 18 | clear(): void { 19 | this.commits = []; 20 | } 21 | 22 | build(label?: string): OperationCommit { 23 | if (!this.commits.length) { 24 | throw new Error('Cannot build a bulk operation without commits.'); 25 | } 26 | 27 | const commits = [...this.commits]; 28 | const first = commits[0]; 29 | const last = commits[commits.length - 1]; 30 | 31 | return { 32 | label: label ?? last.label ?? first.label, 33 | beforeSnapshot: first.beforeSnapshot, 34 | afterSnapshot: last.afterSnapshot, 35 | undo: async () => { 36 | for (const commit of [...commits].reverse()) { 37 | await commit.undo(); 38 | } 39 | }, 40 | redo: async () => { 41 | for (const commit of commits) { 42 | await commit.redo(); 43 | } 44 | }, 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true, 6 | "useDefineForClassFields": false, 7 | "module": "ESNext", 8 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 9 | "types": ["vite/client", "vitest"], 10 | "skipLibCheck": true, 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "verbatimModuleSyntax": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | 19 | /* Path mapping */ 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/*": ["src/*"], 23 | "@/components/*": ["src/components/*"], 24 | "@/core/*": ["src/core/*"], 25 | "@/plugins/*": ["src/plugins/*"], 26 | "@/rendering/*": ["src/rendering/*"], 27 | "@/services/*": ["src/services/*"], 28 | "@/state/*": ["src/state/*"], 29 | "@/styles/*": ["src/styles/*"], 30 | "@/fw/*": ["src/fw/*"] 31 | }, 32 | 33 | /* Linting */ 34 | "strict": true, 35 | "noUnusedLocals": true, 36 | "noUnusedParameters": true, 37 | "noFallthroughCasesInSwitch": true, 38 | "noUncheckedSideEffectImports": true 39 | // Enable highlighting and error reporting for unused code 40 | // Remove erasableSyntaxOnly (not a valid TS option) 41 | }, 42 | "include": ["src"] 43 | } 44 | -------------------------------------------------------------------------------- /src/services/template-data.ts: -------------------------------------------------------------------------------- 1 | import startupScene from '../templates/startup-scene.pix3scene?raw'; 2 | import testModelGlb from '../templates/Duck.glb?url'; 3 | import pix3LogoUrl from '../templates/pix3-logo.png?url'; 4 | 5 | export type SceneTemplateId = 'startup-scene' | 'default'; 6 | export type BinaryTemplateId = 'Duck.glb' | 'pix3-logo.png'; 7 | export type ImageTemplateId = 'pix3-logo.png'; 8 | 9 | export interface SceneTemplateDescriptor { 10 | readonly id: SceneTemplateId; 11 | readonly contents: string; 12 | readonly title: string; 13 | readonly description?: string; 14 | } 15 | 16 | export interface BinaryTemplateDescriptor { 17 | readonly id: BinaryTemplateId; 18 | readonly url: string; 19 | } 20 | 21 | export const sceneTemplates: SceneTemplateDescriptor[] = [ 22 | { 23 | id: 'startup-scene', 24 | contents: startupScene, 25 | title: 'Startup Scene', 26 | description: 'Default Pix3 scene with environment root, basic lighting, camera, and UI sprite.', 27 | }, 28 | { 29 | id: 'default', 30 | contents: startupScene, 31 | title: 'Default Scene', 32 | description: 'Fallback template used when a requested template is missing.', 33 | }, 34 | ]; 35 | 36 | export const binaryTemplates: BinaryTemplateDescriptor[] = [ 37 | { 38 | id: 'Duck.glb', 39 | url: testModelGlb, 40 | }, 41 | { 42 | id: 'pix3-logo.png', 43 | url: pix3LogoUrl, 44 | }, 45 | ]; 46 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | import { proxy, snapshot, type Snapshot } from 'valtio/vanilla'; 2 | 3 | import type { AppState } from './AppState'; 4 | import { createInitialAppState } from './AppState'; 5 | 6 | export const appState = proxy(createInitialAppState()); 7 | 8 | export type AppStateSnapshot = Snapshot; 9 | 10 | export const getAppStateSnapshot = (): AppStateSnapshot => snapshot(appState); 11 | 12 | /** 13 | * Clears the application state back to its default snapshot. Use sparingly—ideally 14 | * only from bootstrapping flows or test fixtures—so that commands remain the 15 | * primary mutation mechanism in production code. 16 | */ 17 | export const resetAppState = (): void => { 18 | const defaults = createInitialAppState(); 19 | appState.project = defaults.project; 20 | appState.scenes = defaults.scenes; 21 | appState.selection = defaults.selection; 22 | appState.ui = defaults.ui; 23 | appState.operations = defaults.operations; 24 | appState.telemetry = defaults.telemetry; 25 | }; 26 | 27 | export { DEFAULT_THEME, THEME_IDS, createInitialAppState } from './AppState'; 28 | 29 | export type { 30 | AppState, 31 | OperationState, 32 | PanelVisibilityState, 33 | ProjectState, 34 | ProjectStatus, 35 | SceneDescriptor, 36 | SceneHierarchyState, 37 | SceneLoadState, 38 | ScenesState, 39 | SelectionState, 40 | TelemetryState, 41 | ThemeName, 42 | UIState, 43 | } from './AppState'; 44 | -------------------------------------------------------------------------------- /src/features/history/RedoCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | type CommandPreconditionResult, 7 | } from '@/core/command'; 8 | import { OperationService } from '@/services/OperationService'; 9 | 10 | export class RedoCommand extends CommandBase { 11 | readonly metadata: CommandMetadata = { 12 | id: 'edit.redo', 13 | title: 'Redo', 14 | description: 'Redo the last undone action', 15 | keywords: ['redo', 'history'], 16 | menuPath: 'edit', 17 | shortcut: '⌘⇧Z', 18 | addToMenu: true, 19 | menuOrder: 1, 20 | }; 21 | 22 | private readonly operations: OperationService; 23 | 24 | constructor(operations: OperationService) { 25 | super(); 26 | this.operations = operations; 27 | } 28 | 29 | preconditions(_context: CommandContext): CommandPreconditionResult { 30 | if (!this.operations.history.canRedo) { 31 | return { 32 | canExecute: false, 33 | reason: 'No actions available to redo', 34 | scope: 'service', 35 | recoverable: false, 36 | }; 37 | } 38 | 39 | return { canExecute: true }; 40 | } 41 | 42 | async execute(_context: CommandContext): Promise> { 43 | const success = await this.operations.redo(); 44 | 45 | return { 46 | didMutate: success, 47 | payload: undefined, 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/features/history/UndoCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | type CommandPreconditionResult, 7 | } from '@/core/command'; 8 | import { OperationService } from '@/services/OperationService'; 9 | 10 | export class UndoCommand extends CommandBase { 11 | readonly metadata: CommandMetadata = { 12 | id: 'edit.undo', 13 | title: 'Undo', 14 | description: 'Undo the last action', 15 | keywords: ['undo', 'revert', 'history'], 16 | menuPath: 'edit', 17 | shortcut: '⌘Z', 18 | addToMenu: true, 19 | menuOrder: 0, 20 | }; 21 | 22 | private readonly operations: OperationService; 23 | 24 | constructor(operations: OperationService) { 25 | super(); 26 | this.operations = operations; 27 | } 28 | 29 | preconditions(_context: CommandContext): CommandPreconditionResult { 30 | if (!this.operations.history.canUndo) { 31 | return { 32 | canExecute: false, 33 | reason: 'No actions available to undo', 34 | scope: 'service', 35 | recoverable: false, 36 | }; 37 | } 38 | 39 | return { canExecute: true }; 40 | } 41 | 42 | async execute(_context: CommandContext): Promise> { 43 | const success = await this.operations.undo(); 44 | 45 | return { 46 | didMutate: success, 47 | payload: undefined, 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pix3", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint src --ext .ts,.js", 11 | "lint:fix": "eslint src --ext .ts,.js --fix", 12 | "format": "prettier --write src/**/*.{ts,css}", 13 | "format:check": "prettier --check src/**/*.{ts,css}", 14 | "test": "vitest run", 15 | "type-check": "tsc --noEmit" 16 | }, 17 | "dependencies": { 18 | "@types/three": "^0.180.0", 19 | "ajv": "^8.17.1", 20 | "ajv-formats": "^3.0.1", 21 | "feather-icons": "^4.29.2", 22 | "golden-layout": "^2.6.0", 23 | "lit": "^3.3.1", 24 | "pixi.js": "^8.13.2", 25 | "reflect-metadata": "^0.2.2", 26 | "three": "^0.180.0", 27 | "valtio": "^2.1.8", 28 | "yaml": "^2.6.0" 29 | }, 30 | "devDependencies": { 31 | "@eslint/eslintrc": "^3.3.1", 32 | "@types/feather-icons": "^4.29.4", 33 | "@types/node": "^24.5.2", 34 | "@typescript-eslint/eslint-plugin": "^8.44.1", 35 | "@typescript-eslint/parser": "^8.44.1", 36 | "eslint": "^9.36.0", 37 | "eslint-config-prettier": "^10.1.8", 38 | "eslint-plugin-lit": "^2.1.1", 39 | "eslint-plugin-lit-a11y": "^5.1.1", 40 | "eslint-plugin-prettier": "^5.5.4", 41 | "happy-dom": "^18.0.1", 42 | "jsdom": "^27.0.0", 43 | "prettier": "^3.6.2", 44 | "typescript": "~5.8.3", 45 | "vite": "^7.1.7", 46 | "vitest": "^3.2.4" 47 | }, 48 | "overrides": {} 49 | } 50 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-toolbar-button.ts.css: -------------------------------------------------------------------------------- 1 | pix3-toolbar-button { 2 | display: inline-flex; 3 | align-items: center; 4 | justify-content: center; 5 | border-radius: 0.5rem; 6 | background: var(--pix3-toolbar-button-background, rgba(60, 68, 82, 0.32)); 7 | color: var(--pix3-toolbar-button-foreground, rgba(245, 247, 250, 0.95)); 8 | font-size: 0.82rem; 9 | font-weight: 600; 10 | letter-spacing: 0.02em; 11 | text-transform: none; 12 | line-height: 1; 13 | cursor: pointer; 14 | user-select: none; 15 | padding: 0; 16 | width: 32px; 17 | height: 32px; 18 | transition: 19 | background 120ms ease, 20 | box-shadow 120ms ease, 21 | transform 120ms ease; 22 | } 23 | 24 | pix3-toolbar-button:focus-visible { 25 | outline: none; 26 | box-shadow: 0 0 0 2px rgba(255, 207, 51, 0.85); 27 | } 28 | 29 | pix3-toolbar-button:hover:not([disabled]) { 30 | background: rgba(82, 94, 114, 0.48); 31 | transform: translateY(-1px); 32 | } 33 | 34 | pix3-toolbar-button:active:not([disabled]) { 35 | transform: translateY(0); 36 | } 37 | 38 | pix3-toolbar-button[toggled] { 39 | background: rgba(255, 207, 51, 0.3); 40 | box-shadow: inset 0 0 0 1px rgba(255, 207, 51, 0.48); 41 | } 42 | 43 | pix3-toolbar-button[toggled]:hover:not([disabled]) { 44 | background: rgba(255, 207, 51, 0.4); 45 | } 46 | 47 | pix3-toolbar-button[disabled] { 48 | cursor: not-allowed; 49 | opacity: 0.55; 50 | box-shadow: none; 51 | transform: none; 52 | pointer-events: none; 53 | } 54 | 55 | .toolbar-button { 56 | display: inline-flex; 57 | align-items: center; 58 | justify-content: center; 59 | gap: 0.4rem; 60 | } 61 | 62 | pix3-toolbar-button[icon-only] .toolbar-button { 63 | gap: 0; 64 | } 65 | -------------------------------------------------------------------------------- /src/ui/pix3-editor-shell.ts.css: -------------------------------------------------------------------------------- 1 | pix3-editor-shell { 2 | display: block; 3 | inline-size: 100%; 4 | block-size: 100%; 5 | } 6 | 7 | .editor-shell { 8 | display: grid; 9 | grid-template-rows: auto 1fr; 10 | min-block-size: 100vh; 11 | min-block-size: 100dvh; 12 | background: var(--pix3-shell-background, #1b1e24); 13 | color: #f3f4f6; 14 | } 15 | 16 | pix3-toolbar { 17 | --pix3-toolbar-background: rgba(19, 22, 27, 0.95); 18 | --pix3-toolbar-foreground: rgba(243, 244, 246, 0.92); 19 | inline-size: 100%; 20 | border-bottom: 1px solid rgba(255, 255, 255, 0.08); 21 | backdrop-filter: blur(18px); 22 | } 23 | 24 | .product-title { 25 | margin: 0; 26 | font-family: 'Inter', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif; 27 | font-size: 0.85rem; 28 | font-weight: 600; 29 | letter-spacing: 0.05em; 30 | text-transform: uppercase; 31 | color: rgba(243, 244, 246, 0.72); 32 | } 33 | 34 | .toolbar-content { 35 | display: flex; 36 | align-items: center; 37 | justify-content: flex-end; 38 | gap: 1rem; 39 | inline-size: 100%; 40 | } 41 | 42 | .workspace { 43 | position: relative; 44 | min-height: 0; 45 | block-size: 100%; 46 | } 47 | 48 | .layout-host { 49 | position: absolute; 50 | inset: 0; 51 | } 52 | 53 | .loading-overlay { 54 | position: absolute; 55 | inset: 0; 56 | display: grid; 57 | place-items: center; 58 | background: rgba(18, 20, 24, 0.72); 59 | } 60 | 61 | /* Welcome UI moved into component */ 62 | 63 | .loading-label { 64 | padding: 0.5rem 0.75rem; 65 | border-radius: 0.5rem; 66 | background: rgba(34, 38, 44, 0.86); 67 | box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); 68 | font-size: 0.85rem; 69 | letter-spacing: 0.04em; 70 | } 71 | -------------------------------------------------------------------------------- /src/nodes/2D/Sprite2D.ts: -------------------------------------------------------------------------------- 1 | import { Node2D, type Node2DProps } from '@/nodes/Node2D'; 2 | import type { PropertySchema } from '@/fw'; 3 | 4 | export interface Sprite2DProps extends Omit { 5 | texturePath?: string | null; 6 | } 7 | 8 | export class Sprite2D extends Node2D { 9 | readonly texturePath: string | null; 10 | 11 | constructor(props: Sprite2DProps) { 12 | super(props, 'Sprite2D'); 13 | this.texturePath = props.texturePath ?? null; 14 | } 15 | 16 | /** 17 | * Get the property schema for Sprite2D. 18 | * Extends Node2D schema with sprite-specific properties. 19 | */ 20 | static getPropertySchema(): PropertySchema { 21 | const baseSchema = Node2D.getPropertySchema(); 22 | 23 | return { 24 | nodeType: 'Sprite2D', 25 | extends: 'Node2D', 26 | properties: [ 27 | ...baseSchema.properties, 28 | { 29 | name: 'texturePath', 30 | type: 'string', 31 | ui: { 32 | label: 'Texture', 33 | description: 'Path to the sprite texture', 34 | group: 'Sprite', 35 | }, 36 | getValue: (node: unknown) => (node as Sprite2D).texturePath ?? '', 37 | setValue: () => { 38 | // Texture path is read-only in constructor, but would be updated via operations 39 | // This is here for completeness; actual updates happen via UpdateObjectPropertyOperation 40 | }, 41 | }, 42 | ], 43 | groups: { 44 | ...baseSchema.groups, 45 | Sprite: { 46 | label: 'Sprite', 47 | description: 'Sprite-specific properties', 48 | expanded: true, 49 | }, 50 | }, 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/features/scene/ReparentNodeCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | ReparentNodeOperation, 10 | type ReparentNodeOperationParams, 11 | } from '@/features/scene/ReparentNodeOperation'; 12 | 13 | export class ReparentNodeCommand extends CommandBase { 14 | readonly metadata: CommandMetadata = { 15 | id: 'scene.reparent-node', 16 | title: 'Reparent Node', 17 | description: 'Move a node to a new parent or change its order', 18 | keywords: ['reparent', 'move', 'hierarchy', 'reorganize'], 19 | }; 20 | 21 | private readonly params: ReparentNodeOperationParams; 22 | 23 | constructor(params: ReparentNodeOperationParams) { 24 | super(); 25 | this.params = params; 26 | } 27 | 28 | preconditions(context: CommandContext) { 29 | const { state } = context; 30 | const activeSceneId = state.scenes.activeSceneId; 31 | 32 | if (!activeSceneId) { 33 | return { 34 | canExecute: false, 35 | reason: 'An active scene is required to reparent nodes', 36 | scope: 'scene' as const, 37 | }; 38 | } 39 | 40 | return { canExecute: true }; 41 | } 42 | 43 | async execute(context: CommandContext): Promise> { 44 | const operationService = context.container.getService( 45 | context.container.getOrCreateToken(OperationService) 46 | ); 47 | 48 | const op = new ReparentNodeOperation(this.params); 49 | const pushed = await operationService.invokeAndPush(op); 50 | 51 | return { didMutate: pushed, payload: undefined }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/features/selection/SelectObjectCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | SelectObjectOperation, 10 | type SelectObjectParams, 11 | } from '@/features/selection/SelectObjectOperation'; 12 | 13 | export type SelectObjectExecutePayload = object; 14 | 15 | export class SelectObjectCommand extends CommandBase { 16 | readonly metadata: CommandMetadata = { 17 | id: 'scene.select-object', 18 | title: 'Select Object', 19 | description: 'Select one or more objects in the scene hierarchy', 20 | keywords: ['select', 'object', 'node', 'hierarchy'], 21 | }; 22 | 23 | private readonly params: SelectObjectParams; 24 | 25 | constructor(params: SelectObjectParams) { 26 | super(); 27 | this.params = params; 28 | } 29 | 30 | async execute( 31 | context: CommandContext 32 | ): Promise> { 33 | const operations = context.container.getService( 34 | context.container.getOrCreateToken(OperationService) 35 | ); 36 | const op = new SelectObjectOperation(this.params); 37 | const pushed = await operations.invokeAndPush(op); 38 | return { didMutate: pushed, payload: {} }; 39 | } 40 | } 41 | 42 | export const createSelectObjectCommand = (params: SelectObjectParams) => 43 | new SelectObjectCommand(params); 44 | export const selectObject = (nodeId: string | null) => new SelectObjectCommand({ nodeId }); 45 | export const toggleObjectSelection = (nodeId: string) => 46 | new SelectObjectCommand({ nodeId, additive: true }); 47 | export const selectObjectRange = (nodeId: string) => 48 | new SelectObjectCommand({ nodeId, range: true }); 49 | -------------------------------------------------------------------------------- /src/features/properties/UpdateObjectPropertyCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | UpdateObjectPropertyOperation, 10 | type UpdateObjectPropertyParams, 11 | } from '@/features/properties/UpdateObjectPropertyOperation'; 12 | import { SceneManager } from '@/core/SceneManager'; 13 | 14 | export type UpdateObjectPropertyExecutePayload = object; 15 | 16 | export class UpdateObjectPropertyCommand extends CommandBase< 17 | UpdateObjectPropertyExecutePayload, 18 | void 19 | > { 20 | readonly metadata: CommandMetadata = { 21 | id: 'scene.update-object-property', 22 | title: 'Update Object Property', 23 | description: 'Update a property on a scene object', 24 | keywords: ['update', 'property', 'object', 'node', 'transform'], 25 | }; 26 | 27 | private readonly params: UpdateObjectPropertyParams; 28 | 29 | constructor(params: UpdateObjectPropertyParams) { 30 | super(); 31 | this.params = params; 32 | } 33 | 34 | preconditions(context: CommandContext) { 35 | const sceneManager = context.container.getService( 36 | context.container.getOrCreateToken(SceneManager) 37 | ); 38 | return { canExecute: Boolean(sceneManager.getActiveSceneGraph()) }; 39 | } 40 | 41 | async execute( 42 | context: CommandContext 43 | ): Promise> { 44 | const operations = context.container.getService( 45 | context.container.getOrCreateToken(OperationService) 46 | ); 47 | const op = new UpdateObjectPropertyOperation(this.params); 48 | const pushed = await operations.invokeAndPush(op); 49 | return { didMutate: pushed, payload: {} }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/nodes/3D/GeometryMesh.ts: -------------------------------------------------------------------------------- 1 | import { BoxGeometry, Mesh, MeshStandardMaterial, Color, BufferGeometry, Material } from 'three'; 2 | import { Node3D, type Node3DProps } from '@/nodes/Node3D'; 3 | 4 | export interface GeometryMeshProps extends Omit { 5 | geometry?: string; 6 | size?: [number, number, number]; 7 | material?: { color?: string; roughness?: number; metalness?: number }; 8 | } 9 | 10 | export class GeometryMesh extends Node3D { 11 | private _geometry?: BufferGeometry; 12 | private _material?: Material; 13 | 14 | constructor(props: GeometryMeshProps) { 15 | super(props, 'GeometryMesh'); 16 | 17 | const geometryKind = (props.geometry ?? 'box').toLowerCase(); 18 | const size = props.size ?? [1, 1, 1]; 19 | 20 | let geometry: BufferGeometry; 21 | switch (geometryKind) { 22 | case 'box': 23 | default: 24 | geometry = new BoxGeometry(size[0], size[1], size[2]); 25 | break; 26 | } 27 | 28 | const mat = props.material ?? {}; 29 | const color = new Color(mat.color ?? '#4e8df5').convertSRGBToLinear(); 30 | const roughness = typeof mat.roughness === 'number' ? mat.roughness : 0.35; 31 | const metalness = typeof mat.metalness === 'number' ? mat.metalness : 0.25; 32 | 33 | const material = new MeshStandardMaterial({ color, roughness, metalness }); 34 | 35 | const mesh = new Mesh(geometry, material); 36 | mesh.castShadow = true; 37 | mesh.receiveShadow = true; 38 | mesh.name = `${this.name}-Mesh`; 39 | this.add(mesh); 40 | 41 | this._geometry = geometry; 42 | this._material = material; 43 | } 44 | 45 | dispose(): void { 46 | try { 47 | this._geometry?.dispose(); 48 | // eslint-disable-next-line no-empty 49 | } catch {} 50 | try { 51 | (this._material as unknown as { dispose?: () => void })?.dispose?.(); 52 | // eslint-disable-next-line no-empty 53 | } catch {} 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ui/scene-tree/node-visuals.helper.ts: -------------------------------------------------------------------------------- 1 | import { NodeBase } from '@/nodes/NodeBase'; 2 | import { Node2D } from '@/nodes/Node2D'; 3 | import { Node3D } from '@/nodes/Node3D'; 4 | import { Sprite2D } from '@/nodes/2D/Sprite2D'; 5 | import { Group2D } from '@/nodes/2D/Group2D'; 6 | import { Camera3D } from '@/nodes/3D/Camera3D'; 7 | import { DirectionalLightNode } from '@/nodes/3D/DirectionalLightNode'; 8 | import { MeshInstance } from '@/nodes/3D/MeshInstance'; 9 | import { GeometryMesh } from '@/nodes/3D/GeometryMesh'; 10 | 11 | // Color constants for node types 12 | const NODE_2D_COLOR = '#96cbf6ff'; 13 | const NODE_3D_COLOR = '#fe9ebeff'; 14 | 15 | /** 16 | * Determines the visual representation (color and icon) for a scene node in the UI. 17 | * This keeps UI concerns separate from the core node data model. 18 | * @param node The scene node. 19 | * @returns An object with the color and icon name for the node. 20 | */ 21 | export function getNodeVisuals(node: NodeBase): { color: string; icon: string } { 22 | if (node instanceof Sprite2D) { 23 | return { color: NODE_2D_COLOR, icon: 'image' }; 24 | } 25 | if (node instanceof Group2D) { 26 | return { color: NODE_2D_COLOR, icon: 'layout' }; 27 | } 28 | if (node instanceof Node2D) { 29 | return { color: NODE_2D_COLOR, icon: 'square' }; 30 | } 31 | if (node instanceof DirectionalLightNode) { 32 | return { color: NODE_3D_COLOR, icon: 'sun' }; 33 | } 34 | if (node instanceof MeshInstance) { 35 | return { color: NODE_3D_COLOR, icon: 'package' }; 36 | } 37 | if (node instanceof GeometryMesh) { 38 | return { color: NODE_3D_COLOR, icon: 'box' }; 39 | } 40 | if (node instanceof Camera3D) { 41 | return { color: NODE_3D_COLOR, icon: 'camera' }; 42 | } 43 | if (node instanceof Node3D) { 44 | return { color: NODE_3D_COLOR, icon: 'box' }; 45 | } 46 | 47 | // Default for NodeBase or other types 48 | return { color: '#fff', icon: 'box' }; 49 | } 50 | -------------------------------------------------------------------------------- /src/features/scene/AddModelCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | AddModelOperation, 10 | type AddModelOperationParams, 11 | } from '@/features/scene/AddModelOperation'; 12 | import { SceneManager } from '@/core/SceneManager'; 13 | 14 | export type AddModelCommandPayload = object; 15 | 16 | export class AddModelCommand extends CommandBase { 17 | readonly metadata: CommandMetadata = { 18 | id: 'scene.add-model', 19 | title: 'Add Model to Scene', 20 | description: 'Add a model file to the scene hierarchy', 21 | keywords: ['add', 'model', 'mesh', 'glb', 'gltf', 'import'], 22 | }; 23 | 24 | private readonly params: AddModelOperationParams; 25 | 26 | constructor(params: AddModelOperationParams) { 27 | super(); 28 | this.params = params; 29 | } 30 | 31 | preconditions(context: CommandContext) { 32 | const sceneManager = context.container.getService( 33 | context.container.getOrCreateToken(SceneManager) 34 | ); 35 | const hasActiveScene = Boolean(sceneManager.getActiveSceneGraph()); 36 | if (!hasActiveScene) { 37 | return { 38 | canExecute: false, 39 | reason: 'An active scene is required to add a model', 40 | scope: 'scene' as const, 41 | }; 42 | } 43 | return { canExecute: true }; 44 | } 45 | 46 | async execute(context: CommandContext): Promise> { 47 | const operations = context.container.getService( 48 | context.container.getOrCreateToken(OperationService) 49 | ); 50 | const op = new AddModelOperation(this.params); 51 | const pushed = await operations.invokeAndPush(op); 52 | return { didMutate: pushed, payload: {} }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test_assets/test_project_1/New Scene.pix3scene: -------------------------------------------------------------------------------- 1 | # Pix3 Scene File (YAML) 2 | # Simple template loaded at startup. Contains a 3D environment root with a box mesh, 3 | # a directional light, a perspective camera, and a 2D UI layer with a sprite. 4 | version: "1.0.0" 5 | metadata: 6 | author: "Pix3 Auto Template" 7 | created: "2025-09-27" 8 | description: "Starter scene with box, light, camera and logo sprite" 9 | root: 10 | - id: environment-root 11 | type: Node3D 12 | name: Environment Root 13 | children: 14 | - id: main-camera 15 | type: Camera3D 16 | name: Main Camera 17 | properties: 18 | projection: perspective 19 | fov: 60 20 | near: 0.1 21 | far: 1000 22 | transform: 23 | position: [0, 2, 6] 24 | rotationEuler: [ -10, 0, 0 ] 25 | - id: key-light 26 | type: DirectionalLightNode 27 | name: Key Light 28 | properties: 29 | intensity: 1.2 30 | color: "#ffffff" 31 | transform: 32 | position: [5, 8, 3] 33 | rotationEuler: [-45, 35, 0] 34 | - id: demo-box 35 | type: GeometryMesh 36 | name: Demo Box 37 | properties: 38 | geometry: box 39 | size: [1, 1, 1] 40 | material: 41 | type: standard 42 | color: "#ff00ff" 43 | transform: 44 | position: [0, 0.5, 0] 45 | rotationEuler: [0, 45, 0] 46 | - id: demo-glb 47 | type: MeshInstance 48 | name: Demo Glb 49 | properties: 50 | src: 'templ://Duck.glb' 51 | size: [1, 1, 1] 52 | - id: ui-layer 53 | type: Node2D 54 | name: UI Layer 55 | children: 56 | - id: logo-sprite 57 | type: Sprite2D 58 | name: Logo Sprite 59 | properties: 60 | source: "templ://pix3-logo.png" 61 | pivot: [0.5, 0.5] 62 | size: [256, 256] 63 | opacity: 1 64 | -------------------------------------------------------------------------------- /src/features/scene/ReloadSceneCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | type CommandPreconditionResult, 7 | } from '@/core/command'; 8 | import { OperationService } from '@/services/OperationService'; 9 | import { 10 | ReloadSceneOperation, 11 | type ReloadSceneOperationParams, 12 | } from '@/features/scene/ReloadSceneOperation'; 13 | 14 | /** 15 | * ReloadSceneCommand reloads a scene from its file source. 16 | * Typically triggered automatically when external file changes are detected. 17 | * Not exposed in menu - used internally for file watching. 18 | */ 19 | export class ReloadSceneCommand extends CommandBase { 20 | readonly metadata: CommandMetadata = { 21 | id: 'scene.reload', 22 | title: 'Reload Scene', 23 | description: 'Reload scene from file (internal, triggered by external change)', 24 | keywords: ['reload', 'scene', 'refresh', 'file-change'], 25 | }; 26 | 27 | private readonly params: ReloadSceneOperationParams; 28 | 29 | constructor(params: ReloadSceneOperationParams) { 30 | super(); 31 | this.params = params; 32 | } 33 | 34 | preconditions(context: CommandContext): CommandPreconditionResult { 35 | const { state } = context; 36 | 37 | const descriptor = state.scenes.descriptors[this.params.sceneId]; 38 | if (!descriptor) { 39 | return { 40 | canExecute: false, 41 | reason: 'Scene not found for reload', 42 | scope: 'scene', 43 | recoverable: false, 44 | }; 45 | } 46 | 47 | return { canExecute: true }; 48 | } 49 | 50 | async execute(context: CommandContext): Promise> { 51 | const operationService = context.container.getService( 52 | context.container.getOrCreateToken(OperationService) 53 | ); 54 | 55 | const op = new ReloadSceneOperation(this.params); 56 | await operationService.invokeAndPush(op); 57 | 58 | return { didMutate: true, payload: undefined }; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import tseslint from '@typescript-eslint/eslint-plugin'; 3 | import tsparser from '@typescript-eslint/parser'; 4 | import prettier from 'eslint-plugin-prettier'; 5 | import lit from 'eslint-plugin-lit'; 6 | import litA11y from 'eslint-plugin-lit-a11y'; 7 | 8 | export default [ 9 | js.configs.recommended, 10 | { 11 | files: ['src/**/*.ts', 'src/**/*.js'], 12 | ignores: ['dist/**', 'node_modules/**'], 13 | languageOptions: { 14 | parser: tsparser, 15 | parserOptions: { 16 | ecmaVersion: 'latest', 17 | sourceType: 'module', 18 | project: './tsconfig.json', 19 | }, 20 | globals: { 21 | console: 'readonly', 22 | document: 'readonly', 23 | window: 'readonly', 24 | requestAnimationFrame: 'readonly', 25 | cancelAnimationFrame: 'readonly', 26 | queueMicrotask: 'readonly', 27 | CustomEvent: 'readonly', 28 | FileSystemHandleKind: 'readonly', 29 | DataTransferItemList: 'readonly', 30 | DragEvent: 'readonly', 31 | MouseEvent: 'readonly', 32 | KeyboardEvent: 'readonly', 33 | Event: 'readonly', 34 | }, 35 | }, 36 | plugins: { 37 | '@typescript-eslint': tseslint, 38 | prettier, 39 | lit, 40 | 'lit-a11y': litA11y, 41 | }, 42 | rules: { 43 | ...tseslint.configs.recommended.rules, 44 | 'prettier/prettier': 'error', 45 | '@typescript-eslint/no-unused-vars': ['error', { 46 | argsIgnorePattern: '^_', 47 | varsIgnorePattern: '^_', 48 | }], 49 | '@typescript-eslint/no-explicit-any': 'warn', 50 | '@typescript-eslint/explicit-function-return-type': 'off', 51 | '@typescript-eslint/explicit-module-boundary-types': 'off', 52 | 'no-undef': 'off', // Turn off base no-undef as it doesn't understand browser globals 53 | 54 | // Lit-specific rules 55 | 'lit/no-invalid-html': 'error', 56 | 'lit/no-useless-template-literals': 'error', 57 | 'lit-a11y/click-events-have-key-events': 'error', 58 | 'lit-a11y/anchor-is-valid': 'error', 59 | }, 60 | }, 61 | ]; -------------------------------------------------------------------------------- /src/services/TemplateService.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from '@/fw/di'; 2 | 3 | import type { BinaryTemplateDescriptor, SceneTemplateDescriptor } from './template-data'; 4 | import { binaryTemplates, sceneTemplates } from './template-data'; 5 | 6 | export type TemplateScheme = 'templ'; 7 | 8 | @injectable() 9 | export class TemplateService { 10 | private readonly sceneTemplateMap = new Map(); 11 | private readonly binaryTemplateMap = new Map(); 12 | 13 | constructor() { 14 | for (const descriptor of sceneTemplates) { 15 | this.sceneTemplateMap.set(descriptor.id, descriptor); 16 | } 17 | for (const descriptor of binaryTemplates) { 18 | this.binaryTemplateMap.set(descriptor.id, descriptor); 19 | } 20 | } 21 | 22 | getSceneTemplate(id: string): string { 23 | const descriptor = this.sceneTemplateMap.get(id) ?? this.sceneTemplateMap.get('default'); 24 | if (!descriptor) { 25 | throw new Error(`No scene template registered for id "${id}".`); 26 | } 27 | return descriptor.contents; 28 | } 29 | 30 | getBinaryTemplateUrl(id: string): string { 31 | const descriptor = this.binaryTemplateMap.get(id); 32 | if (!descriptor) { 33 | throw new Error(`No binary template registered for id "${id}".`); 34 | } 35 | return descriptor.url; 36 | } 37 | 38 | resolveSceneTemplateFromUri(uri: string): string { 39 | const templateId = this.extractTemplateId(uri); 40 | return this.getSceneTemplate(templateId); 41 | } 42 | 43 | resolveBinaryTemplateUrl(uri: string): string { 44 | const templateId = this.extractTemplateId(uri); 45 | return this.getBinaryTemplateUrl(templateId); 46 | } 47 | 48 | private extractTemplateId(uri: string): string { 49 | const match = /^templ:\/\/(.+)$/i.exec(uri.trim()); 50 | if (!match) { 51 | throw new Error(`Unsupported template URI: ${uri}`); 52 | } 53 | return match[1] || 'default'; 54 | } 55 | } 56 | 57 | export const DEFAULT_TEMPLATE_SCENE_ID = 'startup-scene'; 58 | 59 | export type TemplateLookupError = Error & { 60 | readonly code: 'TEMPLATE_NOT_FOUND' | 'INVALID_TEMPLATE_URI'; 61 | }; 62 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-confirm-dialog.ts.css: -------------------------------------------------------------------------------- 1 | pix3-confirm-dialog { 2 | display: contents; 3 | } 4 | 5 | .dialog-backdrop { 6 | position: fixed; 7 | top: 0; 8 | left: 0; 9 | right: 0; 10 | bottom: 0; 11 | background: rgba(0, 0, 0, 0.5); 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | z-index: 1000; 16 | } 17 | 18 | .dialog-content { 19 | background: var(--color-bg-secondary, #2a2a2a); 20 | border: 1px solid var(--color-border, #444); 21 | border-radius: 8px; 22 | padding: 1.5rem; 23 | max-width: 400px; 24 | box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 25 | animation: slideUp 0.2s ease-out; 26 | } 27 | 28 | @keyframes slideUp { 29 | from { 30 | opacity: 0; 31 | transform: translateY(20px); 32 | } 33 | to { 34 | opacity: 1; 35 | transform: translateY(0); 36 | } 37 | } 38 | 39 | .dialog-title { 40 | margin: 0 0 1rem 0; 41 | font-size: 1.2em; 42 | color: var(--color-text-primary, #fff); 43 | } 44 | 45 | .dialog-message { 46 | margin: 0.5rem 0; 47 | color: var(--color-text-secondary, #ccc); 48 | line-height: 1.5; 49 | white-space: pre-wrap; 50 | word-break: break-word; 51 | } 52 | 53 | .dialog-actions { 54 | display: flex; 55 | gap: 0.75rem; 56 | justify-content: flex-end; 57 | margin-top: 1.5rem; 58 | } 59 | 60 | .dialog-actions button { 61 | padding: 0.5rem 1rem; 62 | border: none; 63 | border-radius: 4px; 64 | cursor: pointer; 65 | font-size: 0.95em; 66 | font-weight: 500; 67 | transition: all 0.2s ease; 68 | } 69 | 70 | .btn-cancel { 71 | background: var(--color-bg-tertiary, #3a3a3a); 72 | color: var(--color-text-primary, #fff); 73 | border: 1px solid var(--color-border, #444); 74 | } 75 | 76 | .btn-cancel:hover { 77 | background: var(--color-bg-hover, #444); 78 | } 79 | 80 | .btn-confirm { 81 | background: var(--color-primary, #2196f3); 82 | color: #fff; 83 | } 84 | 85 | .btn-confirm:hover { 86 | background: var(--color-primary-hover, #1976d2); 87 | } 88 | 89 | .btn-confirm.dangerous { 90 | background: var(--color-danger, #d32f2f); 91 | color: #fff; 92 | } 93 | 94 | .btn-confirm.dangerous:hover { 95 | background: var(--color-danger-hover, #b71c1c); 96 | } 97 | -------------------------------------------------------------------------------- /src/core/Operation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createCommandContext, 3 | snapshotState, 4 | type CommandContext as CommandContextType, 5 | } from '@/core/command'; 6 | import { ServiceContainer } from '@/fw/di'; 7 | import { appState, getAppStateSnapshot, type AppState, type AppStateSnapshot } from '@/state'; 8 | 9 | export type OperationContext = CommandContextType; 10 | 11 | export interface OperationMetadata { 12 | readonly id: string; 13 | readonly title: string; 14 | readonly description?: string; 15 | readonly affectsNodeStructure?: boolean; 16 | readonly tags?: readonly string[]; 17 | readonly coalesceKey?: string; 18 | } 19 | 20 | export interface OperationCommit { 21 | readonly label?: string; 22 | readonly beforeSnapshot?: AppStateSnapshot; 23 | readonly afterSnapshot?: AppStateSnapshot; 24 | undo(): Promise | void; 25 | redo(): Promise | void; 26 | } 27 | 28 | export interface OperationInvokeResult { 29 | readonly didMutate: boolean; 30 | readonly commit?: OperationCommit; 31 | } 32 | 33 | export interface OperationInvokeOptions { 34 | readonly context?: Partial; 35 | readonly label?: string; 36 | readonly coalesceKey?: string; 37 | readonly beforeSnapshot?: AppStateSnapshot; 38 | readonly afterSnapshot?: AppStateSnapshot; 39 | } 40 | 41 | export interface Operation { 42 | readonly metadata: OperationMetadata; 43 | perform(context: OperationContext): TInvokeResult | Promise; 44 | } 45 | 46 | export abstract class OperationBase< 47 | TInvokeResult extends OperationInvokeResult = OperationInvokeResult, 48 | > implements Operation 49 | { 50 | abstract readonly metadata: OperationMetadata; 51 | 52 | abstract perform(context: OperationContext): TInvokeResult | Promise; 53 | } 54 | 55 | export const createOperationContext = ( 56 | state: AppState = appState, 57 | snapshot: AppStateSnapshot = getAppStateSnapshot(), 58 | container: ServiceContainer = ServiceContainer.getInstance() 59 | ): OperationContext => createCommandContext(state, snapshot, container); 60 | 61 | export const snapshotOperationState = (state: AppState): AppStateSnapshot => snapshotState(state); 62 | -------------------------------------------------------------------------------- /src/core/SceneManager.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from '@/fw/di'; 2 | import { SceneLoader, type ParseSceneOptions } from './SceneLoader'; 3 | import { SceneSaver } from './SceneSaver'; 4 | import type { NodeBase } from '../nodes/NodeBase'; 5 | 6 | export interface SceneGraph { 7 | version: string; 8 | description?: string; 9 | rootNodes: NodeBase[]; 10 | nodeMap: Map; 11 | metadata: Record; 12 | } 13 | 14 | @injectable() 15 | export class SceneManager { 16 | @inject(SceneLoader) private readonly sceneLoader!: SceneLoader; 17 | @inject(SceneSaver) private readonly sceneSaver!: SceneSaver; 18 | 19 | private readonly sceneGraphs = new Map(); 20 | private activeSceneId: string | null = null; 21 | 22 | constructor() {} 23 | 24 | async parseScene(sceneText: string, options: ParseSceneOptions = {}): Promise { 25 | return await this.sceneLoader.parseScene(sceneText, options); 26 | } 27 | 28 | serializeScene(graph: SceneGraph): string { 29 | return this.sceneSaver.serializeScene(graph); 30 | } 31 | 32 | setActiveSceneGraph(sceneId: string, graph: SceneGraph): void { 33 | this.sceneGraphs.set(sceneId, graph); 34 | this.activeSceneId = sceneId; 35 | // Debug logging to help trace when scenes are registered as active 36 | if (process.env.NODE_ENV === 'development') { 37 | console.debug('[SceneManager] setActiveSceneGraph', { 38 | sceneId, 39 | rootCount: graph.rootNodes.length, 40 | }); 41 | } 42 | } 43 | 44 | getSceneGraph(sceneId: string): SceneGraph | null { 45 | const graph = this.sceneGraphs.get(sceneId) ?? null; 46 | 47 | return graph; 48 | } 49 | 50 | getActiveSceneGraph(): SceneGraph | null { 51 | if (!this.activeSceneId) { 52 | return null; 53 | } 54 | const graph = this.sceneGraphs.get(this.activeSceneId) ?? null; 55 | 56 | return graph; 57 | } 58 | 59 | removeSceneGraph(sceneId: string): void { 60 | this.sceneGraphs.delete(sceneId); 61 | if (this.activeSceneId === sceneId) { 62 | this.activeSceneId = null; 63 | } 64 | } 65 | 66 | dispose(): void { 67 | this.sceneGraphs.clear(); 68 | this.activeSceneId = null; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ui/assets-browser/asset-browser-panel.ts.css: -------------------------------------------------------------------------------- 1 | asset-browser-panel { 2 | display: block; 3 | height: 100%; 4 | } 5 | 6 | pix3-panel { 7 | height: 100%; 8 | } 9 | 10 | .asset-list { 11 | display: grid; 12 | gap: 0.75rem; 13 | } 14 | 15 | .delete-modal-backdrop { 16 | position: fixed; 17 | top: 0; 18 | left: 0; 19 | right: 0; 20 | bottom: 0; 21 | background: rgba(0, 0, 0, 0.5); 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | z-index: 1000; 26 | } 27 | 28 | .delete-modal { 29 | background: var(--color-bg-secondary, #2a2a2a); 30 | border: 1px solid var(--color-border, #444); 31 | border-radius: 8px; 32 | padding: 1.5rem; 33 | max-width: 400px; 34 | box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 35 | animation: slideUp 0.2s ease-out; 36 | } 37 | 38 | @keyframes slideUp { 39 | from { 40 | opacity: 0; 41 | transform: translateY(20px); 42 | } 43 | to { 44 | opacity: 1; 45 | transform: translateY(0); 46 | } 47 | } 48 | 49 | .delete-modal h2 { 50 | margin: 0 0 1rem 0; 51 | font-size: 1.2em; 52 | color: var(--color-text-primary, #fff); 53 | } 54 | 55 | .delete-modal p { 56 | margin: 0.5rem 0; 57 | color: var(--color-text-secondary, #ccc); 58 | line-height: 1.5; 59 | } 60 | 61 | .delete-modal strong { 62 | color: var(--color-text-primary, #fff); 63 | word-break: break-word; 64 | } 65 | 66 | .modal-actions { 67 | display: flex; 68 | gap: 0.75rem; 69 | justify-content: flex-end; 70 | margin-top: 1.5rem; 71 | } 72 | 73 | .modal-actions button { 74 | padding: 0.5rem 1rem; 75 | border: none; 76 | border-radius: 4px; 77 | cursor: pointer; 78 | font-size: 0.95em; 79 | font-weight: 500; 80 | transition: all 0.2s ease; 81 | } 82 | 83 | .btn-cancel { 84 | background: var(--color-bg-tertiary, #3a3a3a); 85 | color: var(--color-text-primary, #fff); 86 | border: 1px solid var(--color-border, #444); 87 | } 88 | 89 | .btn-cancel:hover { 90 | background: var(--color-bg-hover, #444); 91 | } 92 | 93 | .btn-delete { 94 | background: var(--color-danger, #d32f2f); 95 | color: #fff; 96 | } 97 | 98 | .btn-delete:hover { 99 | background: var(--color-danger-hover, #b71c1c); 100 | } 101 | -------------------------------------------------------------------------------- /src/features/scene/UpdateGroup2DSizeCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase as CommandBaseImpl, 3 | type CommandExecutionResult as CommandExecutionResultType, 4 | type CommandContext as CommandContextType, 5 | type CommandMetadata as CommandMetadataType, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | UpdateGroup2DSizeOperation, 10 | type UpdateGroup2DSizeParams, 11 | } from './UpdateGroup2DSizeOperation'; 12 | import { SceneManager } from '@/core/SceneManager'; 13 | import { Group2D } from '@/nodes/2D/Group2D'; 14 | 15 | export type UpdateGroup2DSizeExecutePayload = object; 16 | 17 | export class UpdateGroup2DSizeCommand extends CommandBaseImpl< 18 | UpdateGroup2DSizeExecutePayload, 19 | void 20 | > { 21 | readonly metadata: CommandMetadataType = { 22 | id: 'scene.update-group2d-size', 23 | title: 'Update Group2D Size', 24 | description: 'Update the width and height of a Group2D node', 25 | keywords: ['update', 'property', 'group2d', 'size'], 26 | }; 27 | 28 | private readonly params: UpdateGroup2DSizeParams; 29 | 30 | constructor(params: UpdateGroup2DSizeParams) { 31 | super(); 32 | this.params = params; 33 | } 34 | 35 | preconditions(context: CommandContextType) { 36 | const sceneManager = context.container.getService( 37 | context.container.getOrCreateToken(SceneManager) 38 | ); 39 | const sceneGraph = sceneManager.getActiveSceneGraph(); 40 | if (!sceneGraph) { 41 | return { canExecute: false, reason: 'No active scene' }; 42 | } 43 | 44 | const node = sceneGraph.nodeMap.get(this.params.nodeId); 45 | if (!(node instanceof Group2D)) { 46 | return { canExecute: false, reason: 'Node is not a Group2D' }; 47 | } 48 | 49 | return { canExecute: true }; 50 | } 51 | 52 | async execute( 53 | context: CommandContextType 54 | ): Promise> { 55 | const operations = context.container.getService( 56 | context.container.getOrCreateToken(OperationService) 57 | ); 58 | const op = new UpdateGroup2DSizeOperation(this.params); 59 | const pushed = await operations.invokeAndPush(op); 60 | return { didMutate: pushed, payload: {} }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ui/viewport/transform-toolbar.ts: -------------------------------------------------------------------------------- 1 | import { html, type TemplateResult } from 'lit'; 2 | import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 3 | import feather from 'feather-icons'; 4 | 5 | import type { TransformMode } from '@/services/ViewportRenderService'; 6 | 7 | export type ModeChangeHandler = (mode: TransformMode) => void; 8 | 9 | // Small, self-contained renderer for the transform toolbar so the markup is 10 | // reusable and separate from the viewport panel logic. 11 | export function renderTransformToolbar( 12 | current: TransformMode, 13 | onChange: ModeChangeHandler 14 | ): TemplateResult { 15 | const transformModes: Array<{ 16 | mode: TransformMode; 17 | iconName: string; 18 | label: string; 19 | key: string; 20 | }> = [ 21 | // use a mouse pointer icon for select mode 22 | { mode: 'select', iconName: 'mouse-pointer', label: 'Select (Q)', key: 'Q' }, 23 | { mode: 'translate', iconName: 'move', label: 'Move (W)', key: 'W' }, 24 | { mode: 'rotate', iconName: 'rotate-cw', label: 'Rotate (E)', key: 'E' }, 25 | { mode: 'scale', iconName: 'maximize', label: 'Scale (R)', key: 'R' }, 26 | ]; 27 | 28 | const renderIcon = (name: string, fallback: string) => { 29 | try { 30 | const icon = (feather as any).icons?.[name]; 31 | if (icon && typeof icon.toSvg === 'function') { 32 | return unsafeHTML(icon.toSvg({ width: 16, height: 16 })); 33 | } 34 | } catch { 35 | // ignore and fall through to fallback 36 | } 37 | return html`${fallback}`; 38 | }; 39 | 40 | return html` 41 |
42 | ${transformModes.map( 43 | ({ mode, iconName, label }) => html` 44 | 56 | ` 57 | )} 58 |
59 | `; 60 | } 61 | 62 | export default renderTransformToolbar; 63 | -------------------------------------------------------------------------------- /src/services/AssetFileActivationService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from '@/fw/di'; 2 | import { CommandDispatcher } from '@/services/CommandDispatcher'; 3 | import { LoadSceneCommand } from '@/features/scene/LoadSceneCommand'; 4 | import { AddModelCommand } from '@/features/scene/AddModelCommand'; 5 | 6 | export interface AssetActivation { 7 | name: string; 8 | path: string; 9 | kind: FileSystemHandleKind; 10 | resourcePath: string | null; 11 | extension: string; // lowercase without dot 12 | } 13 | 14 | /** 15 | * AssetFileActivationService handles opening asset files from the project tree. 16 | * It dispatches appropriate commands based on file type (e.g., LoadSceneCommand for .pix3scene files). 17 | */ 18 | @injectable() 19 | export class AssetFileActivationService { 20 | @inject(CommandDispatcher) 21 | private readonly commandDispatcher!: CommandDispatcher; 22 | 23 | /** 24 | * Handle activation of an asset file from the project tree. 25 | * @param payload File activation details including extension and resource path 26 | */ 27 | async handleActivation(payload: AssetActivation): Promise { 28 | const { extension, resourcePath, name } = payload; 29 | if (!resourcePath) return; 30 | 31 | if (extension === 'pix3scene') { 32 | const sceneId = this.deriveSceneId(resourcePath); 33 | const command = new LoadSceneCommand({ filePath: resourcePath, sceneId }); 34 | await this.commandDispatcher.execute(command); 35 | return; 36 | } 37 | 38 | if (extension === 'glb' || extension === 'gltf') { 39 | const command = new AddModelCommand({ modelPath: resourcePath, modelName: name }); 40 | await this.commandDispatcher.execute(command); 41 | return; 42 | } 43 | 44 | // TODO: other asset types (images -> Sprite2D, audio, prefabs, etc.) 45 | console.info('[AssetFileActivationService] No handler for asset type', payload); 46 | } 47 | 48 | private deriveSceneId(resourcePath: string): string { 49 | const withoutScheme = resourcePath.replace(/^res:\/\//i, '').replace(/^templ:\/\//i, ''); 50 | const withoutExtension = withoutScheme.replace(/\.[^./]+$/i, ''); 51 | const normalized = withoutExtension 52 | .replace(/[^a-z0-9]+/gi, '-') 53 | .replace(/^-+|-+$/g, '') 54 | .toLowerCase(); 55 | return normalized || 'scene'; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/features/scene/CreateBoxCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | CreateBoxOperation, 10 | type CreateBoxOperationParams, 11 | } from '@/features/scene/CreateBoxOperation'; 12 | import { SceneManager } from '@/core/SceneManager'; 13 | 14 | export interface CreateBoxCommandPayload { 15 | nodeId: string; 16 | } 17 | 18 | export class CreateBoxCommand extends CommandBase { 19 | readonly metadata: CommandMetadata = { 20 | id: 'scene.create-box', 21 | title: 'Create Box', 22 | description: 'Create a new box geometry mesh in the scene', 23 | keywords: ['create', 'box', 'geometry', 'mesh', 'add'], 24 | }; 25 | 26 | private readonly params: CreateBoxOperationParams; 27 | 28 | constructor(params: CreateBoxOperationParams = {}) { 29 | super(); 30 | this.params = params; 31 | } 32 | 33 | preconditions(context: CommandContext) { 34 | const sceneManager = context.container.getService( 35 | context.container.getOrCreateToken(SceneManager) 36 | ); 37 | const hasActiveScene = Boolean(sceneManager.getActiveSceneGraph()); 38 | if (!hasActiveScene) { 39 | return { 40 | canExecute: false, 41 | reason: 'An active scene is required to create a box', 42 | scope: 'scene' as const, 43 | }; 44 | } 45 | return { canExecute: true }; 46 | } 47 | 48 | async execute(context: CommandContext): Promise> { 49 | const operationService = context.container.getService( 50 | context.container.getOrCreateToken(OperationService) 51 | ); 52 | const sceneManager = context.container.getService( 53 | context.container.getOrCreateToken(SceneManager) 54 | ); 55 | 56 | const op = new CreateBoxOperation(this.params); 57 | const pushed = await operationService.invokeAndPush(op); 58 | 59 | // Get the created node ID from the scene graph 60 | const activeSceneGraph = sceneManager.getActiveSceneGraph(); 61 | const nodeId = activeSceneGraph?.rootNodes[activeSceneGraph.rootNodes.length - 1]?.nodeId || ''; 62 | 63 | return { didMutate: pushed, payload: { nodeId } }; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-toolbar.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, inject, property, css, unsafeCSS } from '@/fw'; 2 | import { FocusRingService } from '@/services/FocusRingService'; 3 | import styles from './pix3-toolbar.ts.css?raw'; 4 | 5 | @customElement('pix3-toolbar') 6 | export class Pix3Toolbar extends ComponentBase { 7 | static useShadowDom = true; 8 | static styles = css` 9 | ${unsafeCSS(styles)} 10 | `; 11 | @property({ attribute: 'aria-label' }) 12 | label = 'Editor toolbar'; 13 | 14 | @property({ type: Boolean, reflect: true }) 15 | dense = false; 16 | 17 | @inject(FocusRingService) 18 | private readonly focusRing!: FocusRingService; 19 | 20 | private cleanupActions?: () => void; 21 | 22 | disconnectedCallback(): void { 23 | this.cleanupActions?.(); 24 | this.cleanupActions = undefined; 25 | super.disconnectedCallback(); 26 | } 27 | 28 | protected firstUpdated(): void { 29 | this.ensureActionsFocusGroup(); 30 | } 31 | 32 | protected updated(): void { 33 | this.ensureActionsFocusGroup(); 34 | } 35 | 36 | private ensureActionsFocusGroup(): void { 37 | if (this.cleanupActions) { 38 | return; 39 | } 40 | 41 | const root = (this.renderRoot as HTMLElement) ?? this; 42 | const actionsHost = root.querySelector('[data-toolbar-actions]'); 43 | if (!actionsHost) { 44 | return; 45 | } 46 | 47 | this.cleanupActions = this.focusRing.attachRovingFocus(actionsHost, { 48 | selector: 'pix3-toolbar-button:not([disabled])', 49 | orientation: 'horizontal', 50 | focusFirstOnInit: false, 51 | }); 52 | } 53 | 54 | protected render() { 55 | return html` 56 | 72 | `; 73 | } 74 | } 75 | 76 | declare global { 77 | interface HTMLElementTagNameMap { 78 | 'pix3-toolbar': Pix3Toolbar; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/features/scene/CreateGroup2DCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | CreateGroup2DOperation, 10 | type CreateGroup2DOperationParams, 11 | } from '@/features/scene/CreateGroup2DOperation'; 12 | import { SceneManager } from '@/core/SceneManager'; 13 | 14 | export interface CreateGroup2DCommandPayload { 15 | nodeId: string; 16 | } 17 | 18 | export class CreateGroup2DCommand extends CommandBase { 19 | readonly metadata: CommandMetadata = { 20 | id: 'scene.create-group2d', 21 | title: 'Create Group2D', 22 | description: 'Create a new 2D group container in the scene', 23 | keywords: ['create', 'group', '2d', 'container', 'add'], 24 | }; 25 | 26 | private readonly params: CreateGroup2DOperationParams; 27 | 28 | constructor(params: CreateGroup2DOperationParams = {}) { 29 | super(); 30 | this.params = params; 31 | } 32 | 33 | preconditions(context: CommandContext) { 34 | const sceneManager = context.container.getService( 35 | context.container.getOrCreateToken(SceneManager) 36 | ); 37 | const hasActiveScene = Boolean(sceneManager.getActiveSceneGraph()); 38 | if (!hasActiveScene) { 39 | return { 40 | canExecute: false, 41 | reason: 'An active scene is required to create a Group2D', 42 | scope: 'scene' as const, 43 | }; 44 | } 45 | return { canExecute: true }; 46 | } 47 | 48 | async execute( 49 | context: CommandContext 50 | ): Promise> { 51 | const operationService = context.container.getService( 52 | context.container.getOrCreateToken(OperationService) 53 | ); 54 | const sceneManager = context.container.getService( 55 | context.container.getOrCreateToken(SceneManager) 56 | ); 57 | 58 | const op = new CreateGroup2DOperation(this.params); 59 | const pushed = await operationService.invokeAndPush(op); 60 | 61 | // Get the created node ID from the scene graph 62 | const activeSceneGraph = sceneManager.getActiveSceneGraph(); 63 | const nodeId = activeSceneGraph?.rootNodes[activeSceneGraph.rootNodes.length - 1]?.nodeId || ''; 64 | 65 | return { didMutate: pushed, payload: { nodeId } }; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/features/scene/CreateCamera3DCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | CreateCamera3DOperation, 10 | type CreateCamera3DOperationParams, 11 | } from '@/features/scene/CreateCamera3DOperation'; 12 | import { SceneManager } from '@/core/SceneManager'; 13 | 14 | export interface CreateCamera3DCommandPayload { 15 | nodeId: string; 16 | } 17 | 18 | export class CreateCamera3DCommand extends CommandBase { 19 | readonly metadata: CommandMetadata = { 20 | id: 'scene.create-camera3d', 21 | title: 'Create Camera3D', 22 | description: 'Create a new 3D camera in the scene', 23 | keywords: ['create', 'camera', '3d', 'viewport', 'add'], 24 | }; 25 | 26 | private readonly params: CreateCamera3DOperationParams; 27 | 28 | constructor(params: CreateCamera3DOperationParams = {}) { 29 | super(); 30 | this.params = params; 31 | } 32 | 33 | preconditions(context: CommandContext) { 34 | const sceneManager = context.container.getService( 35 | context.container.getOrCreateToken(SceneManager) 36 | ); 37 | const hasActiveScene = Boolean(sceneManager.getActiveSceneGraph()); 38 | if (!hasActiveScene) { 39 | return { 40 | canExecute: false, 41 | reason: 'An active scene is required to create a camera', 42 | scope: 'scene' as const, 43 | }; 44 | } 45 | return { canExecute: true }; 46 | } 47 | 48 | async execute( 49 | context: CommandContext 50 | ): Promise> { 51 | const operationService = context.container.getService( 52 | context.container.getOrCreateToken(OperationService) 53 | ); 54 | const sceneManager = context.container.getService( 55 | context.container.getOrCreateToken(SceneManager) 56 | ); 57 | 58 | const op = new CreateCamera3DOperation(this.params); 59 | const pushed = await operationService.invokeAndPush(op); 60 | 61 | // Get the created node ID from the scene graph 62 | const activeSceneGraph = sceneManager.getActiveSceneGraph(); 63 | const nodeId = activeSceneGraph?.rootNodes[activeSceneGraph.rootNodes.length - 1]?.nodeId || ''; 64 | 65 | return { didMutate: pushed, payload: { nodeId } }; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/features/scene/CreateSprite2DCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | CreateSprite2DOperation, 10 | type CreateSprite2DOperationParams, 11 | } from '@/features/scene/CreateSprite2DOperation'; 12 | import { SceneManager } from '@/core/SceneManager'; 13 | 14 | export interface CreateSprite2DCommandPayload { 15 | nodeId: string; 16 | } 17 | 18 | export class CreateSprite2DCommand extends CommandBase { 19 | readonly metadata: CommandMetadata = { 20 | id: 'scene.create-sprite2d', 21 | title: 'Create Sprite2D', 22 | description: 'Create a new 2D sprite in the scene', 23 | keywords: ['create', 'sprite', '2d', 'image', 'add'], 24 | }; 25 | 26 | private readonly params: CreateSprite2DOperationParams; 27 | 28 | constructor(params: CreateSprite2DOperationParams = {}) { 29 | super(); 30 | this.params = params; 31 | } 32 | 33 | preconditions(context: CommandContext) { 34 | const sceneManager = context.container.getService( 35 | context.container.getOrCreateToken(SceneManager) 36 | ); 37 | const hasActiveScene = Boolean(sceneManager.getActiveSceneGraph()); 38 | if (!hasActiveScene) { 39 | return { 40 | canExecute: false, 41 | reason: 'An active scene is required to create a Sprite2D', 42 | scope: 'scene' as const, 43 | }; 44 | } 45 | return { canExecute: true }; 46 | } 47 | 48 | async execute( 49 | context: CommandContext 50 | ): Promise> { 51 | const operationService = context.container.getService( 52 | context.container.getOrCreateToken(OperationService) 53 | ); 54 | const sceneManager = context.container.getService( 55 | context.container.getOrCreateToken(SceneManager) 56 | ); 57 | 58 | const op = new CreateSprite2DOperation(this.params); 59 | const pushed = await operationService.invokeAndPush(op); 60 | 61 | // Get the created node ID from the scene graph 62 | const activeSceneGraph = sceneManager.getActiveSceneGraph(); 63 | const nodeId = activeSceneGraph?.rootNodes[activeSceneGraph.rootNodes.length - 1]?.nodeId || ''; 64 | 65 | return { didMutate: pushed, payload: { nodeId } }; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/services/ViewportRenderService.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, it, expect, afterEach } from 'vitest'; 2 | import * as THREE from 'three'; 3 | import { ViewportRendererService } from './ViewportRenderService'; 4 | import { Sprite2D } from '@/nodes/2D/Sprite2D'; 5 | 6 | describe('ViewportRendererService', () => { 7 | it('should use ResourceManager.readBlob for templ:// sprite textures', async () => { 8 | const service = new ViewportRendererService(); 9 | 10 | // Create a fake resource manager 11 | const readBlobSpy = vi.fn().mockResolvedValue(new Blob(['fake'])); 12 | Object.defineProperty(service, 'resourceManager', { 13 | value: { readBlob: readBlobSpy }, 14 | configurable: true, 15 | }); 16 | 17 | // Minimal stubs for dependencies used by createSprite2DVisual 18 | (service as any).scene = { add: vi.fn() } as any; 19 | 20 | // Create a sprite with templ scheme 21 | const sprite = new Sprite2D({ id: 'test-sprite', texturePath: 'templ://pix3-logo.png' }); 22 | 23 | // Call private method reflectively 24 | const mesh = (service as any).createSprite2DVisual(sprite); 25 | 26 | expect(mesh).toBeDefined(); 27 | 28 | // Wait a tick for the async fetch to be invoked 29 | await Promise.resolve(); 30 | 31 | expect(readBlobSpy).toHaveBeenCalledWith('templ://pix3-logo.png'); 32 | }); 33 | 34 | it('should not attempt direct load for templ:// when readBlob fails', async () => { 35 | const service = new ViewportRendererService(); 36 | 37 | // readBlob rejects to simulate missing mapping 38 | const readBlobSpy = vi.fn().mockRejectedValue(new Error('Not found')); 39 | Object.defineProperty(service, 'resourceManager', { 40 | value: { readBlob: readBlobSpy }, 41 | configurable: true, 42 | }); 43 | 44 | // stub the TextureLoader to observe direct load attempts 45 | const loadSpy = vi.fn(); 46 | vi.spyOn((THREE as any).TextureLoader.prototype, 'load').mockImplementation(loadSpy); 47 | 48 | (service as any).scene = { add: vi.fn() } as any; 49 | 50 | const sprite = new Sprite2D({ id: 'test-sprite-2', texturePath: 'templ://pix3-logo.png' }); 51 | (service as any).createSprite2DVisual(sprite); 52 | 53 | // Wait a tick to run async failure handler 54 | await Promise.resolve(); 55 | 56 | // Ensure direct loader wasn't invoked for templ:// fallback 57 | expect(loadSpy).not.toHaveBeenCalled(); 58 | }); 59 | 60 | afterEach(() => { 61 | vi.restoreAllMocks(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/features/scene/CreateMeshInstanceCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | CreateMeshInstanceOperation, 10 | type CreateMeshInstanceOperationParams, 11 | } from '@/features/scene/CreateMeshInstanceOperation'; 12 | import { SceneManager } from '@/core/SceneManager'; 13 | 14 | export interface CreateMeshInstanceCommandPayload { 15 | nodeId: string; 16 | } 17 | 18 | export class CreateMeshInstanceCommand extends CommandBase { 19 | readonly metadata: CommandMetadata = { 20 | id: 'scene.create-mesh-instance', 21 | title: 'Create Mesh Instance', 22 | description: 'Create a new 3D mesh instance in the scene', 23 | keywords: ['create', 'mesh', 'model', '3d', 'import', 'add'], 24 | }; 25 | 26 | private readonly params: CreateMeshInstanceOperationParams; 27 | 28 | constructor(params: CreateMeshInstanceOperationParams = {}) { 29 | super(); 30 | this.params = params; 31 | } 32 | 33 | preconditions(context: CommandContext) { 34 | const sceneManager = context.container.getService( 35 | context.container.getOrCreateToken(SceneManager) 36 | ); 37 | const hasActiveScene = Boolean(sceneManager.getActiveSceneGraph()); 38 | if (!hasActiveScene) { 39 | return { 40 | canExecute: false, 41 | reason: 'An active scene is required to create a mesh instance', 42 | scope: 'scene' as const, 43 | }; 44 | } 45 | return { canExecute: true }; 46 | } 47 | 48 | async execute( 49 | context: CommandContext 50 | ): Promise> { 51 | const operationService = context.container.getService( 52 | context.container.getOrCreateToken(OperationService) 53 | ); 54 | const sceneManager = context.container.getService( 55 | context.container.getOrCreateToken(SceneManager) 56 | ); 57 | 58 | const op = new CreateMeshInstanceOperation(this.params); 59 | const pushed = await operationService.invokeAndPush(op); 60 | 61 | // Get the created node ID from the scene graph 62 | const activeSceneGraph = sceneManager.getActiveSceneGraph(); 63 | const nodeId = activeSceneGraph?.rootNodes[activeSceneGraph.rootNodes.length - 1]?.nodeId || ''; 64 | 65 | return { didMutate: pushed, payload: { nodeId } }; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ui/viewport/viewport-panel.ts.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | height: 100%; 4 | width: 100%; 5 | position: relative; 6 | background: radial-gradient(circle at top, #20242a, #14171c 70%); 7 | } 8 | 9 | .panel { 10 | position: relative; 11 | height: 100%; 12 | width: 100%; 13 | outline: none; 14 | } 15 | 16 | .panel:focus-visible { 17 | box-shadow: inset 0 0 0 2px rgba(255, 207, 51, 0.5); 18 | } 19 | 20 | .viewport-canvas { 21 | height: 100%; 22 | width: 100%; 23 | display: block; 24 | } 25 | 26 | .overlay { 27 | position: absolute; 28 | inset: 0; 29 | pointer-events: none; 30 | display: flex; 31 | flex-direction: column; 32 | padding: 1.25rem; 33 | background: linear-gradient(135deg, rgba(255, 255, 255, 0.04) 0%, rgba(10, 13, 18, 0) 55%); 34 | } 35 | 36 | .toolbar-overlay { 37 | display: flex; 38 | justify-content: flex-start; 39 | margin-bottom: auto; 40 | } 41 | 42 | .transform-toolbar { 43 | display: flex; 44 | flex-direction: column; 45 | gap: 0.25rem; 46 | pointer-events: auto; 47 | background: rgba(12, 15, 22, 0.85); 48 | backdrop-filter: blur(14px); 49 | border-radius: 0.5rem; 50 | padding: 0.5rem; 51 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 52 | } 53 | 54 | .toolbar-button { 55 | width: 2.5rem; 56 | height: 2.5rem; 57 | border: none; 58 | border-radius: 0.375rem; 59 | background: rgba(42, 47, 58, 0.6); 60 | color: rgba(240, 244, 250, 0.8); 61 | font-size: 1.1rem; 62 | cursor: pointer; 63 | transition: all 0.15s ease; 64 | display: flex; 65 | align-items: center; 66 | justify-content: center; 67 | position: relative; 68 | } 69 | 70 | /* Ensure injected SVG icons are centered and sized consistently */ 71 | .toolbar-button .toolbar-icon { 72 | display: inline-flex; 73 | align-items: center; 74 | justify-content: center; 75 | width: 1rem; /* 16px */ 76 | height: 1rem; 77 | line-height: 0; 78 | } 79 | 80 | .toolbar-button .toolbar-icon svg { 81 | display: block; 82 | width: 100%; 83 | height: 100%; 84 | } 85 | .toolbar-button:hover { 86 | background: rgba(42, 47, 58, 0.9); 87 | color: rgba(240, 244, 250, 1); 88 | transform: translateY(-1px); 89 | } 90 | 91 | .toolbar-button--active { 92 | background: rgba(255, 207, 51, 0.8); 93 | color: rgba(255, 255, 255, 1); 94 | box-shadow: 0 0 0 2px rgba(255, 207, 51, 0.3); 95 | } 96 | 97 | .toolbar-button--active:hover { 98 | background: rgba(255, 207, 51, 1); 99 | transform: translateY(-1px); 100 | } 101 | -------------------------------------------------------------------------------- /src/templates/startup-scene.pix3scene: -------------------------------------------------------------------------------- 1 | # Pix3 Scene File (YAML) 2 | # Simple template loaded at startup. Contains a 3D environment root with a box mesh, 3 | # a directional light, a perspective camera, and a 2D UI layer with a sprite. 4 | version: 1.0.0 5 | metadata: 6 | author: Pix3 Auto Template 7 | created: 2025-09-27 8 | description: Starter scene with box, light, camera and logo sprite 9 | root: 10 | - id: environment-root 11 | type: Node3D 12 | name: Environment Root 13 | properties: 14 | transform: 15 | position: [0, 0, 0] 16 | rotationEuler: [0, 0, 0] 17 | scale: [1, 1, 1] 18 | children: 19 | - id: main-camera 20 | type: Camera3D 21 | name: Main Camera 22 | properties: 23 | projection: perspective 24 | fov: 60 25 | near: 0.1 26 | far: 1000 27 | transform: 28 | position: [0, 2, 6] 29 | rotationEuler: [-10, 0, 0] 30 | scale: [1, 1, 1] 31 | children: [] 32 | - id: key-light 33 | type: DirectionalLightNode 34 | name: Key Light 35 | properties: 36 | intensity: 1.2 37 | color: "#ffffff" 38 | transform: 39 | position: [5, 8, 3] 40 | rotationEuler: [-45, 35, 0] 41 | scale: [1, 1, 1] 42 | children: [] 43 | - id: demo-box 44 | type: GeometryMesh 45 | name: Demo Box 46 | properties: 47 | geometry: box 48 | size: [1, 1, 1] 49 | material: 50 | type: standard 51 | color: "#ff00ff" 52 | transform: 53 | position: [0, 0.5, 0] 54 | rotationEuler: [0, 45, 0] 55 | scale: [1, 1, 1] 56 | children: [] 57 | - id: demo-glb 58 | type: MeshInstance 59 | name: Demo Glb 60 | properties: 61 | src: templ://Duck.glb 62 | size: [1, 1, 1] 63 | transform: 64 | position: [0, 0, 0] 65 | rotationEuler: [0, 0, 0] 66 | scale: [1, 1, 1] 67 | children: [] 68 | - id: ui-layer 69 | type: Group2D 70 | name: UI Layer 71 | properties: 72 | width: 256 73 | height: 256 74 | position: [0, 0] 75 | scale: [1, 1] 76 | rotation: 0 77 | children: 78 | - id: logo-sprite 79 | type: Sprite2D 80 | name: Logo Sprite 81 | properties: 82 | texturePath: templ://pix3-logo.png 83 | position: [0, 0] 84 | scale: [0.01, 0.01] 85 | rotation: 0 86 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-confirm-dialog.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, property } from '@/fw'; 2 | import './pix3-confirm-dialog.ts.css'; 3 | 4 | @customElement('pix3-confirm-dialog') 5 | export class ConfirmDialog extends ComponentBase { 6 | @property({ type: String, reflect: true }) 7 | public dialogId: string = ''; 8 | 9 | @property({ type: String, reflect: true }) 10 | public title: string = ''; 11 | 12 | @property({ type: String, reflect: true }) 13 | public message: string = ''; 14 | 15 | @property({ type: String, reflect: true }) 16 | public confirmLabel: string = 'Confirm'; 17 | 18 | @property({ type: String, reflect: true }) 19 | public cancelLabel: string = 'Cancel'; 20 | 21 | @property({ type: Boolean, reflect: true }) 22 | public isDangerous: boolean = false; 23 | 24 | protected render() { 25 | return html` 26 |
{ 30 | if (e.key === 'Escape') this.dispatchCancel(); 31 | }} 32 | > 33 |
e.stopPropagation()} 36 | @keydown=${() => {}} 37 | > 38 |

${this.title}

39 |

${this.message}

40 |
41 | 44 | 50 |
51 |
52 |
53 | `; 54 | } 55 | 56 | private onBackdropClick(): void { 57 | this.dispatchCancel(); 58 | } 59 | 60 | private dispatchConfirm(): void { 61 | this.dispatchEvent( 62 | new CustomEvent('dialog-confirmed', { 63 | detail: { dialogId: this.dialogId }, 64 | bubbles: true, 65 | composed: true, 66 | }) 67 | ); 68 | } 69 | 70 | private dispatchCancel(): void { 71 | this.dispatchEvent( 72 | new CustomEvent('dialog-cancelled', { 73 | detail: { dialogId: this.dialogId }, 74 | bubbles: true, 75 | composed: true, 76 | }) 77 | ); 78 | } 79 | } 80 | 81 | declare global { 82 | interface HTMLElementTagNameMap { 83 | 'pix3-confirm-dialog': ConfirmDialog; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/features/scene/CreateDirectionalLightCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandBase, 3 | type CommandExecutionResult, 4 | type CommandMetadata, 5 | type CommandContext, 6 | } from '@/core/command'; 7 | import { OperationService } from '@/services/OperationService'; 8 | import { 9 | CreateDirectionalLightOperation, 10 | type CreateDirectionalLightOperationParams, 11 | } from '@/features/scene/CreateDirectionalLightOperation'; 12 | import { SceneManager } from '@/core/SceneManager'; 13 | 14 | export interface CreateDirectionalLightCommandPayload { 15 | nodeId: string; 16 | } 17 | 18 | export class CreateDirectionalLightCommand extends CommandBase< 19 | CreateDirectionalLightCommandPayload, 20 | void 21 | > { 22 | readonly metadata: CommandMetadata = { 23 | id: 'scene.create-directional-light', 24 | title: 'Create Directional Light', 25 | description: 'Create a new directional light in the scene', 26 | keywords: ['create', 'light', 'directional', '3d', 'add'], 27 | }; 28 | 29 | private readonly params: CreateDirectionalLightOperationParams; 30 | 31 | constructor(params: CreateDirectionalLightOperationParams = {}) { 32 | super(); 33 | this.params = params; 34 | } 35 | 36 | preconditions(context: CommandContext) { 37 | const sceneManager = context.container.getService( 38 | context.container.getOrCreateToken(SceneManager) 39 | ); 40 | const hasActiveScene = Boolean(sceneManager.getActiveSceneGraph()); 41 | if (!hasActiveScene) { 42 | return { 43 | canExecute: false, 44 | reason: 'An active scene is required to create a directional light', 45 | scope: 'scene' as const, 46 | }; 47 | } 48 | return { canExecute: true }; 49 | } 50 | 51 | async execute( 52 | context: CommandContext 53 | ): Promise> { 54 | const operationService = context.container.getService( 55 | context.container.getOrCreateToken(OperationService) 56 | ); 57 | const sceneManager = context.container.getService( 58 | context.container.getOrCreateToken(SceneManager) 59 | ); 60 | 61 | const op = new CreateDirectionalLightOperation(this.params); 62 | const pushed = await operationService.invokeAndPush(op); 63 | 64 | // Get the created node ID from the scene graph 65 | const activeSceneGraph = sceneManager.getActiveSceneGraph(); 66 | const nodeId = activeSceneGraph?.rootNodes[activeSceneGraph.rootNodes.length - 1]?.nodeId || ''; 67 | 68 | return { didMutate: pushed, payload: { nodeId } }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-main-menu.ts.css: -------------------------------------------------------------------------------- 1 | .main-menu { 2 | position: relative; 3 | display: inline-block; 4 | } 5 | 6 | .menu-bar { 7 | display: flex; 8 | align-items: center; 9 | gap: 0; 10 | } 11 | 12 | .pix3-menu-portal { 13 | all: unset; 14 | position: fixed; 15 | z-index: 999999; 16 | } 17 | 18 | .menu-section-button { 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | height: 32px; 23 | padding: 0 12px; 24 | background: none; 25 | border: none; 26 | color: inherit; 27 | cursor: pointer; 28 | font-size: 14px; 29 | font-weight: 500; 30 | border-radius: 4px; 31 | transition: background-color 0.2s ease; 32 | } 33 | 34 | .menu-section-button:hover { 35 | background-color: rgba(255, 255, 255, 0.1); 36 | } 37 | 38 | .menu-section-button--active, 39 | .menu-section-button:active { 40 | background-color: rgba(255, 255, 255, 0.15); 41 | } 42 | 43 | .menu-logo { 44 | height: 32px; 45 | width: auto; 46 | object-fit: contain; 47 | margin-right: 16px; 48 | } 49 | 50 | .menu-dropdown { 51 | background: var(--pix3-dropdown-background, rgba(26, 29, 35, 0.96)); 52 | border: 1px solid rgba(255, 255, 255, 0.08); 53 | border-radius: 8px; 54 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); 55 | z-index: 999999; 56 | min-width: 200px; 57 | overflow: hidden; 58 | } 59 | 60 | .menu-section { 61 | padding: 4px 0; 62 | } 63 | 64 | .section-items { 65 | display: flex; 66 | flex-direction: column; 67 | } 68 | 69 | .menu-item { 70 | display: flex; 71 | align-items: center; 72 | justify-content: space-between; 73 | width: 100%; 74 | padding: 8px 12px; 75 | background: none; 76 | border: none; 77 | color: var(--pix3-dropdown-item-foreground, rgba(245, 247, 250, 0.92)); 78 | font-size: 14px; 79 | text-align: left; 80 | cursor: pointer; 81 | transition: background-color 0.15s ease; 82 | } 83 | 84 | .menu-item:hover:not(.menu-item--disabled) { 85 | background-color: rgba(255, 207, 51, 0.15); 86 | } 87 | 88 | .menu-item:active:not(.menu-item--disabled) { 89 | background-color: rgba(255, 207, 51, 0.2); 90 | } 91 | 92 | .menu-item--disabled { 93 | color: rgba(245, 247, 250, 0.4); 94 | cursor: not-allowed; 95 | opacity: 0.6; 96 | } 97 | 98 | .menu-item-label { 99 | flex: 1; 100 | text-align: left; 101 | } 102 | 103 | .menu-item-shortcut { 104 | margin-left: 12px; 105 | font-size: 12px; 106 | color: rgba(245, 247, 250, 0.6); 107 | white-space: nowrap; 108 | } 109 | 110 | .menu-divider { 111 | height: 1px; 112 | background-color: rgba(255, 255, 255, 0.08); 113 | margin: 4px 0; 114 | } 115 | -------------------------------------------------------------------------------- /test_assets/test_project_1/group2d-test.pix3scene: -------------------------------------------------------------------------------- 1 | version: 1.0.0 2 | metadata: 3 | author: Pix3 Auto Template 4 | created: 2025-09-27 5 | description: Starter scene with box, light, camera and logo sprite 6 | root: 7 | - id: environment-root 8 | type: Node3D 9 | name: Environment Root 10 | properties: 11 | transform: 12 | position: [0, 0, 0] 13 | rotationEuler: [0, 0, 0] 14 | scale: [1, 1, 1] 15 | children: 16 | - id: main-camera 17 | type: Camera3D 18 | name: Main Camera 19 | properties: 20 | projection: perspective 21 | fov: 60 22 | near: 0.1 23 | far: 1000 24 | transform: 25 | position: [0, 2, 6] 26 | rotationEuler: [-10, 0, 0] 27 | scale: [1, 1, 1] 28 | children: [] 29 | - id: key-light 30 | type: DirectionalLightNode 31 | name: Key Light 32 | properties: 33 | intensity: 1.2 34 | color: "#ffffff" 35 | transform: 36 | position: [5, 8, 3] 37 | rotationEuler: [-45, 35, 0] 38 | scale: [1, 1, 1] 39 | children: [] 40 | - id: demo-box 41 | type: GeometryMesh 42 | name: Demo Box 43 | properties: 44 | geometry: box 45 | size: [1, 1, 1] 46 | material: 47 | type: standard 48 | color: "#ff00ff" 49 | transform: 50 | position: [0, 0.5, 0] 51 | rotationEuler: [0, 45, 0] 52 | scale: [1, 1, 1] 53 | children: [] 54 | - id: demo-glb 55 | type: MeshInstance 56 | name: Demo Glb 57 | properties: 58 | src: templ://Duck.glb 59 | size: [1, 1, 1] 60 | transform: 61 | position: [0, 0, 0] 62 | rotationEuler: [0, 0, 0] 63 | scale: [1, 1, 1] 64 | children: [] 65 | - id: group2d-1762809544290-zkya03e 66 | type: Group2D 67 | name: Group2D 68 | properties: 69 | transform: 70 | position: [0, 0] 71 | scale: [1, 1] 72 | rotation: [0, 0, 0, XYZ] 73 | children: 74 | - id: logo-sprite 75 | type: Sprite2D 76 | name: Logo Sprite 77 | properties: 78 | source: templ://pix3-logo.png 79 | pivot: [0.5, 0.5] 80 | size: [256, 256] 81 | opacity: 1 82 | transform: 83 | position: [0, 0] 84 | scale: [1, 1] 85 | rotation: [0, 0, 0, XYZ] 86 | - id: ui-layer 87 | type: Node2D 88 | name: UI Layer 89 | properties: 90 | transform: 91 | position: [0, 0] 92 | scale: [1, 1] 93 | rotation: [0, 0, 0, XYZ] 94 | -------------------------------------------------------------------------------- /src/nodes/2D/Group2D.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from 'three'; 2 | 3 | import { Node2D, type Node2DProps } from '@/nodes/Node2D'; 4 | import type { PropertySchema } from '@/fw'; 5 | 6 | export interface Group2DProps extends Omit { 7 | width?: number; 8 | height?: number; 9 | } 10 | 11 | /** 12 | * Group2D is a container node with a defined size (width, height). 13 | * It allows positioning nested elements aligned to its edges. 14 | * Displayed as a rectangle in the editor. 15 | */ 16 | export class Group2D extends Node2D { 17 | width: number; 18 | height: number; 19 | 20 | constructor(props: Group2DProps) { 21 | super(props, 'Group2D'); 22 | this.width = props.width ?? 100; 23 | this.height = props.height ?? 100; 24 | } 25 | 26 | /** 27 | * Returns the size of the group as a Vector2. 28 | */ 29 | getSize(): Vector2 { 30 | return new Vector2(this.width, this.height); 31 | } 32 | 33 | /** 34 | * Updates the size of the group. 35 | */ 36 | setSize(width: number, height: number): void { 37 | this.width = width; 38 | this.height = height; 39 | } 40 | 41 | /** 42 | * Get the property schema for Group2D. 43 | * Extends Node2D schema with group-specific size properties. 44 | */ 45 | static getPropertySchema(): PropertySchema { 46 | const baseSchema = Node2D.getPropertySchema(); 47 | 48 | return { 49 | nodeType: 'Group2D', 50 | extends: 'Node2D', 51 | properties: [ 52 | ...baseSchema.properties, 53 | { 54 | name: 'width', 55 | type: 'number', 56 | ui: { 57 | label: 'Width', 58 | group: 'Size', 59 | step: 0.01, 60 | precision: 2, 61 | min: 0, 62 | }, 63 | getValue: (node: unknown) => (node as Group2D).width, 64 | setValue: (node: unknown, value: unknown) => { 65 | const n = node as Group2D; 66 | n.width = Number(value); 67 | }, 68 | }, 69 | { 70 | name: 'height', 71 | type: 'number', 72 | ui: { 73 | label: 'Height', 74 | group: 'Size', 75 | step: 0.01, 76 | precision: 2, 77 | min: 0, 78 | }, 79 | getValue: (node: unknown) => (node as Group2D).height, 80 | setValue: (node: unknown, value: unknown) => { 81 | const n = node as Group2D; 82 | n.height = Number(value); 83 | }, 84 | }, 85 | ], 86 | groups: { 87 | ...baseSchema.groups, 88 | Size: { 89 | label: 'Size', 90 | description: 'Group dimensions', 91 | expanded: true, 92 | }, 93 | }, 94 | }; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-panel.ts.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | height: 100%; 4 | } 5 | 6 | .panel { 7 | display: flex; 8 | flex-direction: column; 9 | height: 100%; 10 | background: var( 11 | --pix3-panel-background, 12 | linear-gradient(180deg, rgba(30, 34, 40, 0.96), rgba(20, 22, 28, 0.94)) 13 | ); 14 | color: var(--pix3-panel-foreground, rgba(245, 247, 250, 0.92)); 15 | border: 1px solid rgba(255, 255, 255, 0.04); 16 | border-radius: 0.4rem; 17 | box-shadow: 0 18px 28px rgba(0, 0, 0, 0.22); 18 | overflow: hidden; 19 | } 20 | 21 | .panel__toolbar-slot { 22 | display: block; 23 | flex-shrink: 0; 24 | border-bottom: 1px solid rgba(255, 255, 255, 0.04); 25 | position: relative; 26 | z-index: 10; 27 | } 28 | 29 | :host([accent='primary']) .panel { 30 | border-color: rgba(255, 207, 51, 0.45); 31 | box-shadow: 32 | 0 0 0 1px rgba(255, 207, 51, 0.25), 33 | 0 22px 38px rgba(0, 0, 0, 0.24); 34 | } 35 | 36 | :host([accent='warning']) .panel { 37 | border-color: rgba(255, 176, 64, 0.45); 38 | } 39 | 40 | .panel__header { 41 | display: flex; 42 | align-items: center; 43 | gap: 0.75rem; 44 | padding: 0.75rem 1rem; 45 | background: var(--pix3-panel-header-background, rgba(38, 42, 50, 0.92)); 46 | border-bottom: 1px solid rgba(255, 255, 255, 0.08); 47 | outline: none; 48 | } 49 | 50 | .panel__header:focus-visible { 51 | box-shadow: 0 0 0 2px rgba(255, 207, 51, 0.8); 52 | } 53 | 54 | .panel__heading { 55 | display: flex; 56 | flex-direction: column; 57 | gap: 0.2rem; 58 | } 59 | 60 | .panel__title { 61 | font-family: 'Inter', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif; 62 | font-size: 0.78rem; 63 | font-weight: 600; 64 | letter-spacing: 0.08em; 65 | text-transform: uppercase; 66 | } 67 | 68 | ::slotted([slot='subtitle']) { 69 | font-size: 0.72rem; 70 | color: rgba(245, 247, 250, 0.6); 71 | } 72 | 73 | .panel__actions { 74 | display: inline-flex; 75 | align-items: center; 76 | margin-inline-start: auto; 77 | gap: 0.4rem; 78 | } 79 | 80 | .panel__body { 81 | flex: 1; 82 | min-height: 0; 83 | overflow: auto; 84 | padding: 0; 85 | font-size: 0.9rem; 86 | backdrop-filter: blur(8px); 87 | } 88 | 89 | .panel__description { 90 | margin: 0; 91 | padding: 0.5rem 1rem; 92 | font-size: 0.78rem; 93 | color: rgba(245, 247, 250, 0.62); 94 | border-bottom: 1px solid rgba(255, 255, 255, 0.04); 95 | background: rgba(32, 36, 44, 0.6); 96 | } 97 | 98 | .panel__footer { 99 | padding: 0.5rem 1rem; 100 | border-top: 1px solid rgba(255, 255, 255, 0.04); 101 | font-size: 0.78rem; 102 | color: rgba(245, 247, 250, 0.6); 103 | } 104 | 105 | .panel__footer:empty { 106 | display: none; 107 | } 108 | 109 | ::slotted(.panel-placeholder) { 110 | margin: 0; 111 | color: rgba(245, 247, 250, 0.58); 112 | font-style: italic; 113 | } 114 | -------------------------------------------------------------------------------- /src/services/CommandDispatcher.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from '@/fw/di'; 2 | import { appState, getAppStateSnapshot } from '@/state'; 3 | import { ServiceContainer } from '@/fw/di'; 4 | import { 5 | createCommandContext, 6 | type Command, 7 | type CommandContext, 8 | type CommandPreconditionResult, 9 | } from '@/core/command'; 10 | 11 | /** 12 | * CommandDispatcher executes commands with proper lifecycle management. 13 | * It creates appropriate context, checks preconditions, and invokes command execution. 14 | */ 15 | @injectable() 16 | export class CommandDispatcher { 17 | constructor() {} 18 | 19 | /** 20 | * Execute a command. First checks preconditions, then invokes execute. 21 | * @param command The command to execute 22 | * @returns True if command executed successfully, false if preconditions blocked it 23 | */ 24 | async execute( 25 | command: Command 26 | ): Promise { 27 | const context = this.createContext(); 28 | 29 | // Check preconditions 30 | const preconditionsResult = await this.checkPreconditions(command, context); 31 | if (!preconditionsResult.canExecute) { 32 | console.warn( 33 | `[CommandDispatcher] Command preconditions blocked: ${command.metadata.id}`, 34 | preconditionsResult 35 | ); 36 | return false; 37 | } 38 | 39 | // Execute command 40 | try { 41 | const result = await command.execute(context); 42 | return result.didMutate; 43 | } catch (error) { 44 | console.error(`[CommandDispatcher] Command execution failed: ${command.metadata.id}`, error); 45 | throw error; 46 | } 47 | } 48 | 49 | /** 50 | * Create a command context with current app state and service container. 51 | */ 52 | private createContext(): CommandContext { 53 | return createCommandContext(appState, getAppStateSnapshot(), ServiceContainer.getInstance()); 54 | } 55 | 56 | /** 57 | * Check if command preconditions are satisfied. 58 | */ 59 | private async checkPreconditions( 60 | command: Command, 61 | context: CommandContext 62 | ): Promise { 63 | if (!command.preconditions) { 64 | return { canExecute: true }; 65 | } 66 | 67 | try { 68 | return await Promise.resolve(command.preconditions(context)); 69 | } catch (error) { 70 | console.error( 71 | `[CommandDispatcher] Preconditions check failed: ${command.metadata.id}`, 72 | error 73 | ); 74 | return { canExecute: false, reason: 'Preconditions check failed', scope: 'service' }; 75 | } 76 | } 77 | 78 | dispose(): void { 79 | // No resources to clean up 80 | } 81 | } 82 | 83 | export const resolveCommandDispatcher = (): CommandDispatcher => { 84 | return ServiceContainer.getInstance().getService( 85 | ServiceContainer.getInstance().getOrCreateToken(CommandDispatcher) 86 | ) as CommandDispatcher; 87 | }; 88 | -------------------------------------------------------------------------------- /src/services/DialogService.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from '@/fw/di'; 2 | 3 | export interface DialogOptions { 4 | title: string; 5 | message: string; 6 | confirmLabel?: string; 7 | cancelLabel?: string; 8 | isDangerous?: boolean; 9 | } 10 | 11 | export interface DialogInstance { 12 | id: string; 13 | options: DialogOptions; 14 | resolve: (result: boolean) => void; 15 | reject: (error: Error) => void; 16 | } 17 | 18 | @injectable() 19 | export class DialogService { 20 | private dialogs = new Map(); 21 | private nextId = 0; 22 | private listeners = new Set<(dialogs: DialogInstance[]) => void>(); 23 | 24 | /** 25 | * Show a confirmation dialog and return a promise that resolves to true if confirmed, false if cancelled. 26 | */ 27 | public async showConfirmation(options: DialogOptions): Promise { 28 | return new Promise((resolve, reject) => { 29 | const id = `dialog-${this.nextId++}`; 30 | const instance: DialogInstance = { 31 | id, 32 | options: { 33 | confirmLabel: 'Confirm', 34 | cancelLabel: 'Cancel', 35 | isDangerous: false, 36 | ...options, 37 | }, 38 | resolve: (result: boolean) => { 39 | this.dialogs.delete(id); 40 | this.notifyListeners(); 41 | resolve(result); 42 | }, 43 | reject: (error: Error) => { 44 | this.dialogs.delete(id); 45 | this.notifyListeners(); 46 | reject(error); 47 | }, 48 | }; 49 | 50 | this.dialogs.set(id, instance); 51 | this.notifyListeners(); 52 | }); 53 | } 54 | 55 | /** 56 | * Get all active dialogs for rendering 57 | */ 58 | public getDialogs(): DialogInstance[] { 59 | return Array.from(this.dialogs.values()); 60 | } 61 | 62 | /** 63 | * Subscribe to dialog changes 64 | */ 65 | public subscribe(listener: (dialogs: DialogInstance[]) => void): () => void { 66 | this.listeners.add(listener); 67 | return () => this.listeners.delete(listener); 68 | } 69 | 70 | /** 71 | * Confirm a dialog by ID 72 | */ 73 | public confirm(id: string): void { 74 | const instance = this.dialogs.get(id); 75 | if (instance) { 76 | instance.resolve(true); 77 | } 78 | } 79 | 80 | /** 81 | * Cancel a dialog by ID 82 | */ 83 | public cancel(id: string): void { 84 | const instance = this.dialogs.get(id); 85 | if (instance) { 86 | instance.resolve(false); 87 | } 88 | } 89 | 90 | private notifyListeners(): void { 91 | const dialogs = this.getDialogs(); 92 | for (const listener of this.listeners) { 93 | listener(dialogs); 94 | } 95 | } 96 | 97 | public dispose(): void { 98 | this.dialogs.clear(); 99 | this.listeners.clear(); 100 | } 101 | } 102 | 103 | export function resolveDialogService(): DialogService { 104 | return new DialogService(); 105 | } 106 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-dropdown.ts.css: -------------------------------------------------------------------------------- 1 | pix3-dropdown { 2 | display: inline-flex; 3 | align-items: center; 4 | justify-content: center; 5 | min-width: 2rem; 6 | padding: 0.4rem; 7 | border-radius: 0.5rem; 8 | background: var(--pix3-toolbar-button-background, rgba(60, 68, 82, 0.32)); 9 | color: var(--pix3-toolbar-button-foreground, rgba(245, 247, 250, 0.95)); 10 | cursor: pointer; 11 | user-select: none; 12 | transition: 13 | background 120ms ease, 14 | box-shadow 120ms ease, 15 | transform 120ms ease; 16 | position: relative; 17 | } 18 | 19 | pix3-dropdown:focus-visible { 20 | outline: none; 21 | box-shadow: 0 0 0 2px rgba(255, 207, 51, 0.85); 22 | } 23 | 24 | pix3-dropdown:hover { 25 | background: rgba(82, 94, 114, 0.48); 26 | transform: translateY(-1px); 27 | } 28 | 29 | pix3-dropdown[disabled] { 30 | cursor: not-allowed; 31 | opacity: 0.55; 32 | box-shadow: none; 33 | transform: none; 34 | } 35 | 36 | .dropdown__trigger { 37 | display: inline-flex; 38 | align-items: center; 39 | justify-content: center; 40 | gap: 0.2rem; 41 | } 42 | 43 | .dropdown__icon { 44 | display: inline-flex; 45 | align-items: center; 46 | justify-content: center; 47 | font-size: 1rem; 48 | line-height: 1; 49 | } 50 | 51 | .dropdown__caret { 52 | width: 8px; 53 | height: 8px; 54 | flex-shrink: 0; 55 | opacity: 0.7; 56 | } 57 | 58 | pix3-dropdown[icon-only] .dropdown__caret { 59 | display: none; 60 | } 61 | 62 | .dropdown__menu { 63 | position: absolute; 64 | top: 100%; 65 | left: 0; 66 | min-width: 12rem; 67 | margin-top: 0.5rem; 68 | padding: 0.5rem 0; 69 | background: var(--pix3-dropdown-background, rgba(26, 29, 35, 0.96)); 70 | border: 1px solid rgba(255, 255, 255, 0.08); 71 | border-radius: 0.4rem; 72 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); 73 | z-index: 10000; 74 | } 75 | 76 | .dropdown__item { 77 | display: flex; 78 | align-items: center; 79 | width: 100%; 80 | padding: 0.5rem 1rem; 81 | background: none; 82 | border: none; 83 | color: var(--pix3-dropdown-item-foreground, rgba(245, 247, 250, 0.92)); 84 | font-size: 0.82rem; 85 | text-align: left; 86 | cursor: pointer; 87 | transition: background 120ms ease; 88 | gap: 0.5rem; 89 | } 90 | 91 | .dropdown__item:hover:not(.dropdown__item--disabled) { 92 | background: rgba(255, 207, 51, 0.15); 93 | } 94 | 95 | .dropdown__item:focus-visible { 96 | outline: none; 97 | background: rgba(255, 207, 51, 0.2); 98 | } 99 | 100 | .dropdown__item--disabled { 101 | cursor: not-allowed; 102 | opacity: 0.5; 103 | pointer-events: none; 104 | } 105 | 106 | .dropdown__item-icon { 107 | display: inline-flex; 108 | align-items: center; 109 | justify-content: center; 110 | width: 1rem; 111 | height: 1rem; 112 | flex-shrink: 0; 113 | font-size: 0.9rem; 114 | } 115 | 116 | .dropdown__item-label { 117 | flex: 1; 118 | min-width: 0; 119 | } 120 | 121 | .dropdown__divider { 122 | height: 1px; 123 | margin: 0.25rem 0; 124 | background: rgba(255, 255, 255, 0.08); 125 | } 126 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: 'Inter', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | color-scheme: dark light; 6 | color: #f3f4f6; 7 | background-color: #13161b; 8 | font-synthesis: none; 9 | text-rendering: optimizeLegibility; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | html, 15 | body { 16 | margin: 0; 17 | padding: 0; 18 | height: 100%; 19 | min-height: 100%; 20 | background-color: #13161b; 21 | } 22 | 23 | body { 24 | display: block; 25 | overflow: hidden; 26 | } 27 | 28 | a { 29 | color: inherit; 30 | } 31 | 32 | /* Consistent header font styles */ 33 | h1, 34 | h2, 35 | h3, 36 | h4, 37 | h5, 38 | h6 { 39 | font-family: 'Inter', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif; 40 | } 41 | 42 | #app, 43 | body > pix3-editor { 44 | height: 100%; 45 | } 46 | 47 | /* Global scrollbar styling */ 48 | ::-webkit-scrollbar { 49 | width: 8px; 50 | height: 8px; 51 | background: transparent; 52 | } 53 | 54 | ::-webkit-scrollbar-track { 55 | background: rgba(0, 0, 0, 0.25); 56 | border-radius: 6px; 57 | } 58 | 59 | ::-webkit-scrollbar-thumb { 60 | background: linear-gradient(180deg, rgba(80, 80, 80, 0.7), rgba(48, 48, 48, 0.7)); 61 | border-radius: 6px; 62 | border: 2px solid rgba(0, 0, 0, 0.18); 63 | } 64 | 65 | /* Firefox */ 66 | * { 67 | scrollbar-width: thin; 68 | scrollbar-color: rgba(80, 80, 80, 0.7) rgba(0, 0, 0, 0.25); 69 | } 70 | 71 | /* ======================================== 72 | GoldenLayout Tab Styling with Top Stripe 73 | ======================================== */ 74 | 75 | /* Tab headers container */ 76 | .lm_header { 77 | background: linear-gradient(to bottom, #1f2229, #1a1d23); 78 | border-bottom: 1px solid rgba(255, 255, 255, 0.06); 79 | min-height: 26px; 80 | } 81 | 82 | /* Tab content/title area */ 83 | .lm_tab { 84 | background: linear-gradient(to bottom, #252a32, #1f2329); 85 | min-height: 19px; 86 | } 87 | 88 | .lm_tab span { 89 | padding-top: 3px; 90 | } 91 | 92 | .lm_tab .lm_close_tab { 93 | margin-top: 3px; 94 | } 95 | 96 | /* Top stripe indicator for inactive tabs */ 97 | .lm_tab::before { 98 | content: ''; 99 | position: absolute; 100 | top: 0; 101 | left: 0; 102 | right: 0; 103 | height: 2px; 104 | background: linear-gradient(90deg, rgba(251, 146, 60, 0.15), rgba(251, 146, 60, 0.05)); 105 | transition: all 0.2s ease; 106 | } 107 | 108 | /* Active tab styling */ 109 | .lm_tab.lm_active { 110 | background: linear-gradient(to bottom, #2d3139, #262c33); 111 | } 112 | 113 | /* Active tab top stripe - more prominent */ 114 | .lm_tab.lm_active::before { 115 | height: 2px; 116 | background: linear-gradient(90deg, rgba(255, 176, 112, 0.687), rgba(255, 157, 86, 0.582)); 117 | } 118 | 119 | /* Hover state for inactive tabs */ 120 | .lm_tab:not(.lm_active):hover { 121 | background: linear-gradient(to bottom, #2d3139, #262c33); 122 | color: rgba(243, 244, 246, 0.85); 123 | } 124 | 125 | /* Hover state for active tabs */ 126 | .lm_tab.lm_active:hover { 127 | background: linear-gradient(to bottom, #333841, #2c3238); 128 | } 129 | -------------------------------------------------------------------------------- /src/ui/logs-view/logs-panel.ts.css: -------------------------------------------------------------------------------- 1 | .logs-container { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | overflow: hidden; 6 | font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; 7 | font-size: 12px; 8 | background-color: #1e1e1e; 9 | color: #d4d4d4; 10 | } 11 | 12 | .logs-header { 13 | display: flex; 14 | align-items: center; 15 | gap: 8px; 16 | padding: 8px 12px; 17 | border-bottom: 1px solid #3e3e42; 18 | background-color: #252526; 19 | flex-shrink: 0; 20 | } 21 | 22 | .logs-controls { 23 | display: flex; 24 | gap: 6px; 25 | align-items: center; 26 | } 27 | 28 | .level-toggle { 29 | display: flex; 30 | align-items: center; 31 | gap: 4px; 32 | } 33 | 34 | .level-toggle input[type='checkbox'] { 35 | cursor: pointer; 36 | } 37 | 38 | .level-toggle label { 39 | cursor: pointer; 40 | font-weight: 500; 41 | user-select: none; 42 | } 43 | 44 | .level-toggle.debug label { 45 | color: #858585; 46 | } 47 | 48 | .level-toggle.info label { 49 | color: #4ec9b0; 50 | } 51 | 52 | .level-toggle.warn label { 53 | color: #ce9178; 54 | } 55 | 56 | .level-toggle.error label { 57 | color: #f48771; 58 | } 59 | 60 | .clear-btn { 61 | padding: 4px 8px; 62 | background-color: #3e3e42; 63 | border: 1px solid #555; 64 | border-radius: 3px; 65 | color: #d4d4d4; 66 | cursor: pointer; 67 | font-size: 11px; 68 | transition: background-color 0.2s; 69 | margin-left: auto; 70 | } 71 | 72 | .clear-btn:hover { 73 | background-color: #4e4e52; 74 | } 75 | 76 | .logs-content { 77 | flex: 1; 78 | overflow-y: auto; 79 | overflow-x: hidden; 80 | padding: 0; 81 | } 82 | 83 | .logs-list { 84 | list-style: none; 85 | margin: 0; 86 | padding: 0; 87 | } 88 | 89 | .log-entry { 90 | padding: 4px 12px; 91 | border-bottom: 1px solid #2d2d30; 92 | display: flex; 93 | gap: 8px; 94 | align-items: flex-start; 95 | line-height: 1.4; 96 | white-space: pre-wrap; 97 | word-wrap: break-word; 98 | } 99 | 100 | .log-entry:hover { 101 | background-color: #2d2d30; 102 | } 103 | 104 | .log-entry.debug { 105 | color: #858585; 106 | } 107 | 108 | .log-entry.info { 109 | color: #4ec9b0; 110 | } 111 | 112 | .log-entry.warn { 113 | color: #ce9178; 114 | } 115 | 116 | .log-entry.error { 117 | color: #f48771; 118 | } 119 | 120 | .log-level { 121 | flex-shrink: 0; 122 | width: 40px; 123 | font-weight: bold; 124 | text-align: left; 125 | } 126 | 127 | .log-message { 128 | flex: 1; 129 | word-break: break-word; 130 | } 131 | 132 | .log-timestamp { 133 | flex-shrink: 0; 134 | width: 80px; 135 | text-align: right; 136 | font-size: 10px; 137 | color: #858585; 138 | } 139 | 140 | .logs-empty { 141 | display: flex; 142 | align-items: center; 143 | justify-content: center; 144 | height: 100%; 145 | color: #858585; 146 | font-style: italic; 147 | } 148 | 149 | /* Scrollbar styling */ 150 | .logs-content::-webkit-scrollbar { 151 | width: 12px; 152 | } 153 | 154 | .logs-content::-webkit-scrollbar-track { 155 | background: #1e1e1e; 156 | } 157 | 158 | .logs-content::-webkit-scrollbar-thumb { 159 | background: #424242; 160 | border-radius: 6px; 161 | } 162 | 163 | .logs-content::-webkit-scrollbar-thumb:hover { 164 | background: #4e4e52; 165 | } 166 | -------------------------------------------------------------------------------- /src/nodes/Node2D.ts: -------------------------------------------------------------------------------- 1 | import { MathUtils, Vector2 } from 'three'; 2 | 3 | import { NodeBase, type NodeBaseProps } from './NodeBase'; 4 | import type { PropertySchema } from '@/fw'; 5 | 6 | export interface Node2DProps extends Omit { 7 | position?: Vector2; 8 | scale?: Vector2; 9 | rotation?: number; // degrees 10 | } 11 | 12 | export class Node2D extends NodeBase { 13 | constructor(props: Node2DProps, nodeType: string = 'Node2D') { 14 | super({ ...props, type: nodeType }); 15 | 16 | const position = props.position ?? new Vector2(0, 0); 17 | this.position.set(position.x, position.y, 0); 18 | 19 | const scale = props.scale ?? new Vector2(1, 1); 20 | this.scale.set(scale.x, scale.y, 1); 21 | 22 | const rotationDegrees = props.rotation ?? 0; 23 | const rotationRadians = MathUtils.degToRad(rotationDegrees); 24 | this.rotation.set(0, 0, rotationRadians); 25 | } 26 | 27 | /** 28 | * Get the property schema for Node2D. 29 | * Extends NodeBase schema with 2D-specific transform properties. 30 | */ 31 | static getPropertySchema(): PropertySchema { 32 | const baseSchema = NodeBase.getPropertySchema(); 33 | 34 | return { 35 | nodeType: 'Node2D', 36 | extends: 'NodeBase', 37 | properties: [ 38 | ...baseSchema.properties, 39 | { 40 | name: 'position', 41 | type: 'vector2', 42 | ui: { 43 | label: 'Position', 44 | group: 'Transform', 45 | step: 0.01, 46 | precision: 2, 47 | }, 48 | getValue: (node: unknown) => { 49 | const n = node as Node2D; 50 | return { x: n.position.x, y: n.position.y }; 51 | }, 52 | setValue: (node: unknown, value: unknown) => { 53 | const n = node as Node2D; 54 | const v = value as { x: number; y: number }; 55 | n.position.x = v.x; 56 | n.position.y = v.y; 57 | }, 58 | }, 59 | { 60 | name: 'rotation', 61 | type: 'number', 62 | ui: { 63 | label: 'Rotation', 64 | description: 'Z-axis rotation', 65 | group: 'Transform', 66 | step: 0.1, 67 | precision: 1, 68 | unit: '°', 69 | }, 70 | getValue: (node: unknown) => { 71 | const n = node as Node2D; 72 | return n.rotation.z * (180 / Math.PI); // Convert radians to degrees 73 | }, 74 | setValue: (node: unknown, value: unknown) => { 75 | const n = node as Node2D; 76 | n.rotation.z = Number(value) * (Math.PI / 180); // Convert degrees to radians 77 | }, 78 | }, 79 | { 80 | name: 'scale', 81 | type: 'vector2', 82 | ui: { 83 | label: 'Scale', 84 | group: 'Transform', 85 | step: 0.01, 86 | precision: 2, 87 | min: 0, 88 | }, 89 | getValue: (node: unknown) => { 90 | const n = node as Node2D; 91 | return { x: n.scale.x, y: n.scale.y }; 92 | }, 93 | setValue: (node: unknown, value: unknown) => { 94 | const n = node as Node2D; 95 | const v = value as { x: number; y: number }; 96 | n.scale.x = v.x; 97 | n.scale.y = v.y; 98 | }, 99 | }, 100 | ], 101 | groups: { 102 | ...baseSchema.groups, 103 | Transform: { 104 | label: 'Transform', 105 | description: '2D position, rotation, and scale', 106 | expanded: true, 107 | }, 108 | }, 109 | }; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/nodes/NodeBase.ts: -------------------------------------------------------------------------------- 1 | import { Object3D } from 'three'; 2 | import type { PropertySchema } from '@/fw'; 3 | 4 | export interface NodeMetadata { 5 | [key: string]: unknown; 6 | } 7 | 8 | export interface NodeBaseProps { 9 | id: string; 10 | type?: string; 11 | name?: string; 12 | instancePath?: string | null; 13 | properties?: Record; 14 | metadata?: NodeMetadata; 15 | } 16 | 17 | export class NodeBase extends Object3D { 18 | readonly nodeId: string; 19 | readonly type: string; 20 | override name: string; 21 | override children!: NodeBase[]; 22 | readonly properties: Record; 23 | readonly metadata: NodeMetadata; 24 | readonly instancePath: string | null; 25 | 26 | constructor(props: NodeBaseProps) { 27 | super(); 28 | 29 | this.nodeId = props.id; 30 | this.uuid = props.id; 31 | this.type = props.type ?? 'Group'; 32 | this.name = props.name ?? this.type; 33 | this.properties = { ...(props.properties ?? {}) }; 34 | this.metadata = { ...(props.metadata ?? {}) }; 35 | this.instancePath = props.instancePath ?? null; 36 | 37 | this.userData = { 38 | ...this.userData, 39 | nodeId: this.nodeId, 40 | metadata: this.metadata, 41 | properties: this.properties, 42 | }; 43 | } 44 | 45 | get parentNode(): NodeBase | null { 46 | return this.parent instanceof NodeBase ? this.parent : null; 47 | } 48 | 49 | adoptChild(child: NodeBase): void { 50 | if (child === this) { 51 | throw new Error('Cannot adopt node as its own child.'); 52 | } 53 | this.add(child); 54 | } 55 | 56 | disownChild(child: NodeBase): void { 57 | this.remove(child); 58 | } 59 | 60 | findById(id: string): NodeBase | null { 61 | if (this.nodeId === id) { 62 | return this; 63 | } 64 | for (const child of this.children) { 65 | const match = child instanceof NodeBase ? child.findById(id) : null; 66 | if (match) { 67 | return match; 68 | } 69 | } 70 | return null; 71 | } 72 | 73 | /** 74 | * Get the property schema for this node type. 75 | * Defines all editable properties and their metadata for the inspector. 76 | * Override in subclasses to extend with additional properties. 77 | */ 78 | static getPropertySchema(): PropertySchema { 79 | return { 80 | nodeType: 'NodeBase', 81 | properties: [ 82 | { 83 | name: 'id', 84 | type: 'string', 85 | ui: { 86 | label: 'Node ID', 87 | description: 'Unique identifier for this node', 88 | group: 'Base', 89 | readOnly: true, 90 | }, 91 | getValue: (node: unknown) => (node as NodeBase).nodeId, 92 | setValue: () => { 93 | // Read-only, no-op 94 | }, 95 | }, 96 | { 97 | name: 'name', 98 | type: 'string', 99 | ui: { 100 | label: 'Name', 101 | description: 'Display name for this node', 102 | group: 'Base', 103 | }, 104 | getValue: (node: unknown) => (node as NodeBase).name, 105 | setValue: (node: unknown, value: unknown) => { 106 | (node as NodeBase).name = String(value); 107 | }, 108 | }, 109 | { 110 | name: 'type', 111 | type: 'string', 112 | ui: { 113 | label: 'Type', 114 | description: 'Node type', 115 | group: 'Base', 116 | readOnly: true, 117 | }, 118 | getValue: (node: unknown) => (node as NodeBase).type, 119 | setValue: () => { 120 | // Read-only, no-op 121 | }, 122 | }, 123 | ], 124 | groups: { 125 | Base: { 126 | label: 'Base Properties', 127 | description: 'Core node properties', 128 | expanded: true, 129 | }, 130 | }, 131 | }; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/features/scene/UpdateGroup2DSizeOperation.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Operation, 3 | OperationContext, 4 | OperationInvokeResult, 5 | OperationMetadata, 6 | } from '@/core/Operation'; 7 | import { Group2D } from '@/nodes/2D/Group2D'; 8 | import { SceneManager } from '@/core/SceneManager'; 9 | import { ViewportRendererService } from '@/services/ViewportRenderService'; 10 | 11 | export interface UpdateGroup2DSizeParams { 12 | nodeId: string; 13 | width: number; 14 | height: number; 15 | } 16 | 17 | export class UpdateGroup2DSizeOperation implements Operation { 18 | readonly metadata: OperationMetadata = { 19 | id: 'scene.update-group2d-size', 20 | title: 'Update Group2D Size', 21 | description: 'Update the width and height of a Group2D node', 22 | tags: ['property', 'group2d', 'size'], 23 | }; 24 | 25 | private readonly params: UpdateGroup2DSizeParams; 26 | 27 | constructor(params: UpdateGroup2DSizeParams) { 28 | this.params = params; 29 | } 30 | 31 | async perform(context: OperationContext): Promise { 32 | const { container, state } = context; 33 | const { nodeId, width, height } = this.params; 34 | 35 | const sceneManager = container.getService( 36 | container.getOrCreateToken(SceneManager) 37 | ); 38 | const sceneGraph = sceneManager.getActiveSceneGraph(); 39 | if (!sceneGraph) { 40 | return { didMutate: false }; 41 | } 42 | 43 | const node = sceneGraph.nodeMap.get(nodeId); 44 | if (!(node instanceof Group2D)) { 45 | return { didMutate: false }; 46 | } 47 | 48 | if (width <= 0 || height <= 0) { 49 | return { didMutate: false }; 50 | } 51 | 52 | const previousWidth = node.width; 53 | const previousHeight = node.height; 54 | 55 | if (previousWidth === width && previousHeight === height) { 56 | return { didMutate: false }; 57 | } 58 | 59 | node.setSize(width, height); 60 | 61 | const activeSceneId = state.scenes.activeSceneId; 62 | if (activeSceneId) { 63 | state.scenes.lastLoadedAt = Date.now(); 64 | const descriptor = state.scenes.descriptors[activeSceneId]; 65 | if (descriptor) descriptor.isDirty = true; 66 | } 67 | 68 | try { 69 | const vr = container.getService( 70 | container.getOrCreateToken(ViewportRendererService) 71 | ); 72 | vr.updateSelection(); 73 | // eslint-disable-next-line no-empty 74 | } catch {} 75 | 76 | return { 77 | didMutate: true, 78 | commit: { 79 | label: 'Update Group2D Size', 80 | beforeSnapshot: context.snapshot, 81 | undo: async () => { 82 | node.setSize(previousWidth, previousHeight); 83 | if (activeSceneId) { 84 | state.scenes.lastLoadedAt = Date.now(); 85 | const descriptor = state.scenes.descriptors[activeSceneId]; 86 | if (descriptor) descriptor.isDirty = true; 87 | } 88 | try { 89 | const vr = container.getService( 90 | container.getOrCreateToken(ViewportRendererService) 91 | ); 92 | vr.updateSelection(); 93 | // eslint-disable-next-line no-empty 94 | } catch {} 95 | }, 96 | redo: async () => { 97 | node.setSize(width, height); 98 | if (activeSceneId) { 99 | state.scenes.lastLoadedAt = Date.now(); 100 | const descriptor = state.scenes.descriptors[activeSceneId]; 101 | if (descriptor) descriptor.isDirty = true; 102 | } 103 | try { 104 | const vr = container.getService( 105 | container.getOrCreateToken(ViewportRendererService) 106 | ); 107 | vr.updateSelection(); 108 | // eslint-disable-next-line no-empty 109 | } catch {} 110 | }, 111 | }, 112 | }; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-dropdown-button.ts.css: -------------------------------------------------------------------------------- 1 | pix3-dropdown-button { 2 | display: inline-flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 42px; 6 | height: 32px; 7 | padding: 0; 8 | border-radius: 0.5rem; 9 | background: var(--pix3-toolbar-button-background, rgba(60, 68, 82, 0.32)); 10 | color: var(--pix3-toolbar-button-foreground, rgba(245, 247, 250, 0.95)); 11 | cursor: pointer; 12 | user-select: none; 13 | transition: 14 | background 120ms ease, 15 | box-shadow 120ms ease, 16 | transform 120ms ease; 17 | position: relative; 18 | } 19 | 20 | pix3-dropdown-button:focus-visible { 21 | outline: none; 22 | box-shadow: 0 0 0 2px rgba(255, 207, 51, 0.85); 23 | } 24 | 25 | pix3-dropdown-button:hover:not([disabled]) { 26 | background: rgba(82, 94, 114, 0.48); 27 | transform: translateY(-1px); 28 | } 29 | 30 | pix3-dropdown-button:active:not([disabled]) { 31 | transform: translateY(0); 32 | } 33 | 34 | pix3-dropdown-button[disabled] { 35 | cursor: not-allowed; 36 | opacity: 0.55; 37 | box-shadow: none; 38 | transform: none; 39 | pointer-events: none; 40 | } 41 | 42 | .dropdown__trigger { 43 | display: inline-flex; 44 | align-items: center; 45 | justify-content: center; 46 | gap: 0.2rem; 47 | } 48 | 49 | .dropdown__icon { 50 | display: inline-flex; 51 | align-items: center; 52 | justify-content: center; 53 | font-size: 1.25rem; 54 | line-height: 1; 55 | } 56 | 57 | .dropdown__caret { 58 | width: 8px; 59 | height: 8px; 60 | flex-shrink: 0; 61 | opacity: 0.7; 62 | } 63 | 64 | pix3-dropdown-button[icon-only] .dropdown__caret { 65 | display: none; 66 | } 67 | 68 | .dropdown__menu { 69 | position: absolute; 70 | top: 100%; 71 | left: 0; 72 | min-width: 12rem; 73 | margin-top: 0.5rem; 74 | padding: 0.5rem 0; 75 | background: var(--pix3-dropdown-background, rgba(26, 29, 35, 0.96)); 76 | border: 1px solid rgba(255, 255, 255, 0.08); 77 | border-radius: 0.4rem; 78 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); 79 | z-index: 10000; 80 | } 81 | 82 | .dropdown__item { 83 | display: flex; 84 | align-items: center; 85 | width: 100%; 86 | padding: 0.5rem 1rem; 87 | background: none; 88 | border: none; 89 | color: var(--pix3-dropdown-item-foreground, rgba(245, 247, 250, 0.92)); 90 | font-size: 0.82rem; 91 | text-align: left; 92 | cursor: pointer; 93 | transition: background 120ms ease; 94 | gap: 0.5rem; 95 | } 96 | 97 | .dropdown__item:hover:not(.dropdown__item--disabled) { 98 | background: rgba(255, 207, 51, 0.2); 99 | cursor: pointer; 100 | } 101 | 102 | .dropdown__item:active:not(.dropdown__item--disabled) { 103 | background: rgba(255, 207, 51, 0.25); 104 | } 105 | 106 | .dropdown__item:focus-visible { 107 | outline: none; 108 | background: rgba(255, 207, 51, 0.2); 109 | } 110 | 111 | .dropdown__item--disabled { 112 | cursor: not-allowed; 113 | opacity: 0.5; 114 | pointer-events: none; 115 | } 116 | 117 | .dropdown__item-icon { 118 | display: inline-flex; 119 | align-items: center; 120 | justify-content: center; 121 | width: 1rem; 122 | height: 1rem; 123 | flex-shrink: 0; 124 | font-size: 0.9rem; 125 | } 126 | 127 | .dropdown__item-label { 128 | flex: 1; 129 | min-width: 0; 130 | } 131 | 132 | .dropdown__divider { 133 | height: 1px; 134 | margin: 0.25rem 0; 135 | background: rgba(255, 255, 255, 0.08); 136 | } 137 | 138 | .dropdown__group { 139 | display: flex; 140 | flex-direction: column; 141 | padding: 0.25rem 0; 142 | } 143 | 144 | .dropdown__group + .dropdown__group { 145 | border-top: 1px solid rgba(255, 255, 255, 0.08); 146 | margin-top: 0.25rem; 147 | padding-top: 0.5rem; 148 | } 149 | 150 | .dropdown__group-label { 151 | padding: 0.5rem 1rem 0.25rem 1rem; 152 | font-size: 0.75rem; 153 | font-weight: 600; 154 | text-transform: uppercase; 155 | letter-spacing: 0.05em; 156 | color: rgba(245, 247, 250, 0.6); 157 | border-bottom: 1px solid rgba(255, 255, 255, 0.08); 158 | margin-bottom: 0.25rem; 159 | } 160 | 161 | .dropdown__item--grouped { 162 | padding-left: 2rem; 163 | } 164 | -------------------------------------------------------------------------------- /src/fw/property-schema-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Property Schema Utilities 3 | * 4 | * Helper functions for working with property schemas in the inspector. 5 | */ 6 | 7 | import type { NodeBase } from '@/nodes/NodeBase'; 8 | import type { PropertySchema, PropertyDefinition } from './property-schema'; 9 | 10 | /** 11 | * Get the property schema for a node instance. 12 | * Dynamically resolves the correct schema based on the node's class hierarchy. 13 | */ 14 | export function getNodePropertySchema(node: NodeBase): PropertySchema { 15 | // Try to get schema from the node's constructor 16 | const constructor = node.constructor as { 17 | getPropertySchema?: () => PropertySchema; 18 | }; 19 | 20 | if (typeof constructor.getPropertySchema === 'function') { 21 | return constructor.getPropertySchema(); 22 | } 23 | 24 | // Fallback to base schema if not found 25 | return { nodeType: 'Unknown', properties: [] }; 26 | } 27 | 28 | /** 29 | * Get all properties grouped by category. 30 | */ 31 | export function getPropertiesByGroup(schema: PropertySchema): Map { 32 | const grouped = new Map(); 33 | 34 | for (const prop of schema.properties) { 35 | const group = prop.ui?.group || 'General'; 36 | if (!grouped.has(group)) { 37 | grouped.set(group, []); 38 | } 39 | grouped.get(group)!.push(prop); 40 | } 41 | 42 | return grouped; 43 | } 44 | 45 | /** 46 | * Get a property definition by name. 47 | */ 48 | export function getPropertyDefinition( 49 | schema: PropertySchema, 50 | name: string 51 | ): PropertyDefinition | undefined { 52 | return schema.properties.find(p => p.name === name); 53 | } 54 | 55 | /** 56 | * Get the display value for a property (formatted, converted to display units, etc.) 57 | */ 58 | export function getPropertyDisplayValue(node: NodeBase, prop: PropertyDefinition): string { 59 | const value = prop.getValue(node); 60 | 61 | // Handle different types 62 | if (prop.type === 'number') { 63 | const num = Number(value); 64 | if (isNaN(num)) return '0'; 65 | 66 | const precision = prop.ui?.precision ?? 2; 67 | return parseFloat(num.toFixed(precision)).toString(); 68 | } 69 | 70 | if (prop.type === 'boolean') { 71 | return String(value === true); 72 | } 73 | 74 | if ( 75 | prop.type === 'vector2' || 76 | prop.type === 'vector3' || 77 | prop.type === 'vector4' || 78 | prop.type === 'euler' 79 | ) { 80 | // Vector and Euler values are objects - serialize as JSON 81 | return JSON.stringify(value); 82 | } 83 | 84 | return String(value ?? ''); 85 | } 86 | 87 | /** 88 | * Validate and transform a property value before setting it. 89 | */ 90 | export function validatePropertyValue( 91 | prop: PropertyDefinition, 92 | value: unknown 93 | ): { isValid: boolean; error?: string; transformedValue?: unknown } { 94 | if (prop.validation?.validate) { 95 | const result = prop.validation.validate(value); 96 | if (result === false) { 97 | return { isValid: false, error: 'Invalid value' }; 98 | } 99 | if (typeof result === 'string') { 100 | return { isValid: false, error: result }; 101 | } 102 | } 103 | 104 | let transformedValue = value; 105 | if (prop.validation?.transform) { 106 | transformedValue = prop.validation.transform(value); 107 | } 108 | 109 | return { isValid: true, transformedValue }; 110 | } 111 | 112 | /** 113 | * Set a property value on a node, with validation and transformation. 114 | */ 115 | export function setNodePropertyValue( 116 | node: NodeBase, 117 | prop: PropertyDefinition, 118 | value: unknown 119 | ): { success: boolean; error?: string } { 120 | const validation = validatePropertyValue(prop, value); 121 | 122 | if (!validation.isValid) { 123 | return { success: false, error: validation.error }; 124 | } 125 | 126 | try { 127 | prop.setValue(node, validation.transformedValue); 128 | return { success: true }; 129 | } catch (error) { 130 | return { success: false, error: String(error) }; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-toolbar-button.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, property } from '@/fw'; 2 | import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 3 | import feather from 'feather-icons'; 4 | import './pix3-toolbar-button.ts.css'; 5 | 6 | @customElement('pix3-toolbar-button') 7 | export class Pix3ToolbarButton extends ComponentBase { 8 | @property({ type: Boolean, reflect: true }) 9 | disabled = false; 10 | 11 | @property({ type: Boolean, reflect: true }) 12 | toggled = false; 13 | 14 | @property({ attribute: 'aria-label' }) 15 | label: string | null = null; 16 | 17 | @property({ type: Boolean, reflect: true }) 18 | iconOnly = false; 19 | 20 | @property({ type: String }) 21 | icon: string | null = null; 22 | 23 | connectedCallback(): void { 24 | super.connectedCallback(); 25 | this.setAttribute('role', 'button'); 26 | this.setAttribute('aria-pressed', String(this.toggled)); 27 | if (!this.hasAttribute('tabindex')) { 28 | this.tabIndex = -1; 29 | } 30 | this.updateAriaDisabled(); 31 | this.setupEventListeners(); 32 | } 33 | 34 | disconnectedCallback(): void { 35 | super.disconnectedCallback(); 36 | this.removeEventListeners(); 37 | } 38 | 39 | protected updated(changed: Map): void { 40 | if (changed.has('toggled')) { 41 | this.setAttribute('aria-pressed', String(this.toggled)); 42 | } 43 | 44 | if (changed.has('disabled')) { 45 | this.updateAriaDisabled(); 46 | } 47 | 48 | if (changed.has('label')) { 49 | if (this.label) { 50 | this.setAttribute('aria-label', this.label); 51 | } else { 52 | this.removeAttribute('aria-label'); 53 | } 54 | } 55 | } 56 | 57 | private keydownHandler = (event: KeyboardEvent) => { 58 | if (this.disabled) { 59 | return; 60 | } 61 | 62 | if (event.key === 'Enter' || event.key === ' ') { 63 | event.preventDefault(); 64 | event.stopPropagation(); 65 | this.click(); 66 | } 67 | }; 68 | 69 | private pointerDownHandler = (event: PointerEvent) => { 70 | if (this.disabled) { 71 | event.preventDefault(); 72 | event.stopPropagation(); 73 | return; 74 | } 75 | this.focus(); 76 | }; 77 | 78 | private clickHandler = (event: MouseEvent) => { 79 | if (this.disabled) { 80 | event.preventDefault(); 81 | event.stopImmediatePropagation(); 82 | } 83 | }; 84 | 85 | private setupEventListeners(): void { 86 | this.addEventListener('keydown', this.keydownHandler); 87 | this.addEventListener('pointerdown', this.pointerDownHandler); 88 | this.addEventListener('click', this.clickHandler, { capture: true }); 89 | } 90 | 91 | private removeEventListeners(): void { 92 | this.removeEventListener('keydown', this.keydownHandler); 93 | this.removeEventListener('pointerdown', this.pointerDownHandler); 94 | this.removeEventListener('click', this.clickHandler, { capture: true }); 95 | } 96 | 97 | private updateAriaDisabled(): void { 98 | if (this.disabled) { 99 | this.setAttribute('aria-disabled', 'true'); 100 | this.tabIndex = -1; 101 | } else { 102 | this.removeAttribute('aria-disabled'); 103 | if (!this.hasAttribute('tabindex')) { 104 | this.tabIndex = -1; 105 | } 106 | } 107 | } 108 | 109 | protected render() { 110 | const iconSvg = this.icon ? this.getIconSvg(this.icon) : null; 111 | return html` 112 | ${iconSvg ? html`${unsafeHTML(iconSvg)}` : null} 113 | 114 | `; 115 | } 116 | 117 | private getIconSvg(iconName: string): string | null { 118 | try { 119 | const icon = (feather.icons as Record)[iconName]; 120 | if (icon && typeof icon.toSvg === 'function') { 121 | return icon.toSvg({ width: 18, height: 18 }); 122 | } 123 | } catch (error) { 124 | console.warn(`[Pix3ToolbarButton] Failed to load icon: ${iconName}`, error); 125 | } 126 | return null; 127 | } 128 | } 129 | 130 | declare global { 131 | interface HTMLElementTagNameMap { 132 | 'pix3-toolbar-button': Pix3ToolbarButton; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/nodes/Node3D.ts: -------------------------------------------------------------------------------- 1 | import { Euler, Vector3 } from 'three'; 2 | 3 | import { NodeBase, type NodeBaseProps } from './NodeBase'; 4 | import type { PropertySchema } from '@/fw'; 5 | 6 | export interface Node3DProps extends Omit { 7 | position?: Vector3; 8 | rotation?: Euler; 9 | rotationOrder?: Euler['order']; 10 | scale?: Vector3; 11 | } 12 | 13 | export class Node3D extends NodeBase { 14 | constructor(props: Node3DProps, nodeType: string = 'Node3D') { 15 | super({ ...props, type: nodeType }); 16 | 17 | if (props.position) { 18 | this.position.copy(props.position); 19 | } 20 | 21 | if (props.rotation) { 22 | this.rotation.copy(props.rotation); 23 | } else { 24 | this.rotation.set(0, 0, 0); 25 | } 26 | 27 | this.rotation.order = props.rotationOrder ?? this.rotation.order; 28 | 29 | if (props.scale) { 30 | this.scale.copy(props.scale); 31 | } 32 | } 33 | 34 | get treeColor(): string { 35 | return '#fe9ebeff'; // pink 36 | } 37 | 38 | get treeIcon(): string { 39 | return 'box'; 40 | } 41 | 42 | /** 43 | * Get the property schema for Node3D. 44 | * Extends NodeBase schema with 3D-specific transform properties. 45 | */ 46 | static getPropertySchema(): PropertySchema { 47 | const baseSchema = NodeBase.getPropertySchema(); 48 | 49 | return { 50 | nodeType: 'Node3D', 51 | extends: 'NodeBase', 52 | properties: [ 53 | ...baseSchema.properties, 54 | { 55 | name: 'position', 56 | type: 'vector3', 57 | ui: { 58 | label: 'Position', 59 | group: 'Transform', 60 | step: 0.01, 61 | precision: 2, 62 | }, 63 | getValue: (node: unknown) => { 64 | const n = node as Node3D; 65 | return { x: n.position.x, y: n.position.y, z: n.position.z }; 66 | }, 67 | setValue: (node: unknown, value: unknown) => { 68 | const n = node as Node3D; 69 | const v = value as { x: number; y: number; z: number }; 70 | n.position.x = v.x; 71 | n.position.y = v.y; 72 | n.position.z = v.z; 73 | }, 74 | }, 75 | { 76 | name: 'rotation', 77 | type: 'euler', 78 | ui: { 79 | label: 'Rotation', 80 | description: 'Pitch (X), Yaw (Y), Roll (Z)', 81 | group: 'Transform', 82 | step: 0.1, 83 | precision: 1, 84 | unit: '°', 85 | }, 86 | getValue: (node: unknown) => { 87 | const n = node as Node3D; 88 | return { 89 | x: n.rotation.x * (180 / Math.PI), 90 | y: n.rotation.y * (180 / Math.PI), 91 | z: n.rotation.z * (180 / Math.PI), 92 | }; 93 | }, 94 | setValue: (node: unknown, value: unknown) => { 95 | const n = node as Node3D; 96 | const v = value as { x: number; y: number; z: number }; 97 | n.rotation.x = v.x * (Math.PI / 180); 98 | n.rotation.y = v.y * (Math.PI / 180); 99 | n.rotation.z = v.z * (Math.PI / 180); 100 | }, 101 | }, 102 | { 103 | name: 'scale', 104 | type: 'vector3', 105 | ui: { 106 | label: 'Scale', 107 | group: 'Transform', 108 | step: 0.01, 109 | precision: 2, 110 | min: 0, 111 | }, 112 | getValue: (node: unknown) => { 113 | const n = node as Node3D; 114 | return { x: n.scale.x, y: n.scale.y, z: n.scale.z }; 115 | }, 116 | setValue: (node: unknown, value: unknown) => { 117 | const n = node as Node3D; 118 | const v = value as { x: number; y: number; z: number }; 119 | n.scale.x = v.x; 120 | n.scale.y = v.y; 121 | n.scale.z = v.z; 122 | }, 123 | }, 124 | ], 125 | groups: { 126 | ...baseSchema.groups, 127 | Transform: { 128 | label: 'Transform', 129 | description: '3D position, rotation, and scale', 130 | expanded: true, 131 | }, 132 | }, 133 | }; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/fw/component-base.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, type CSSResultGroup } from 'lit'; 2 | 3 | /** 4 | * Shared base class for Pix3 UI-компонентов. 5 | * 6 | * По умолчанию рендерит в light DOM, чтобы интегрироваться с глобальными стилями. 7 | * Установите `static useShadowDom = true` в наследнике, чтобы включить shadow DOM. 8 | */ 9 | export class ComponentBase extends LitElement { 10 | protected static useShadowDom = false; 11 | protected static lightDomStyleElement?: HTMLStyleElement; 12 | 13 | // Override createRenderRoot to respect the `useShadowDom` setting 14 | createRenderRoot() { 15 | if ((this.constructor as typeof ComponentBase).useShadowDom) { 16 | return super.createRenderRoot(); // Shadow DOM 17 | } 18 | 19 | return this; // Light DOM 20 | } 21 | 22 | connectedCallback(): void { 23 | if (!(this.constructor as typeof ComponentBase).useShadowDom) { 24 | (this.constructor as typeof ComponentBase).ensureLightDomStyles(this); 25 | } 26 | 27 | super.connectedCallback(); 28 | } 29 | 30 | private static ensureLightDomStyles(instance: ComponentBase): void { 31 | if (typeof document === 'undefined') { 32 | return; 33 | } 34 | 35 | const ctor = this as typeof ComponentBase & { 36 | styles?: CSSResultGroup; 37 | lightDomStyleElement?: HTMLStyleElement | null; 38 | }; 39 | 40 | if (!ctor.styles) { 41 | return; 42 | } 43 | 44 | if (ctor.lightDomStyleElement?.isConnected) { 45 | return; 46 | } 47 | 48 | const cssText = this.flattenStyles(ctor.styles, instance.localName); 49 | if (!cssText.trim()) { 50 | return; 51 | } 52 | 53 | const styleEl = document.createElement('style'); 54 | styleEl.type = 'text/css'; 55 | styleEl.setAttribute('data-pix3-component-style', instance.localName); 56 | styleEl.textContent = cssText; 57 | 58 | document.head.appendChild(styleEl); 59 | ctor.lightDomStyleElement = styleEl; 60 | } 61 | 62 | private static flattenStyles(styles: CSSResultGroup, hostSelector: string): string { 63 | if (Array.isArray(styles)) { 64 | return styles.map(style => this.flattenStyles(style, hostSelector)).join('\n'); 65 | } 66 | 67 | const cssResult = styles as { cssText?: string }; 68 | const cssText = cssResult?.cssText ?? String(styles ?? ''); 69 | return this.scopeHostSelectors(cssText, hostSelector); 70 | } 71 | 72 | private static scopeHostSelectors(cssText: string, hostSelector: string): string { 73 | if (!cssText.includes(':host')) { 74 | return cssText; 75 | } 76 | 77 | let result = ''; 78 | let index = 0; 79 | 80 | while (index < cssText.length) { 81 | const hostIndex = cssText.indexOf(':host', index); 82 | if (hostIndex === -1) { 83 | result += cssText.slice(index); 84 | break; 85 | } 86 | 87 | result += cssText.slice(index, hostIndex); 88 | let cursor = hostIndex + 5; // skip ':host' 89 | 90 | const nextChar = cssText.charAt(cursor); 91 | if (nextChar === '-') { 92 | // Preserve pseudo-selectors like :host-context 93 | result += ':host'; 94 | index = cursor; 95 | continue; 96 | } 97 | 98 | while (/\s/.test(cssText.charAt(cursor))) { 99 | cursor++; 100 | } 101 | 102 | if (cssText.charAt(cursor) === '(') { 103 | cursor++; 104 | let depth = 1; 105 | const selectorStart = cursor; 106 | 107 | while (cursor < cssText.length && depth > 0) { 108 | const char = cssText.charAt(cursor); 109 | if (char === '(') { 110 | depth++; 111 | } else if (char === ')') { 112 | depth--; 113 | } 114 | cursor++; 115 | } 116 | 117 | const selectorContent = cssText.slice(selectorStart, cursor - 1); 118 | const scoped = selectorContent 119 | .split(',') 120 | .map(part => { 121 | const trimmed = part.trim(); 122 | return trimmed ? `${hostSelector}${trimmed}` : hostSelector; 123 | }) 124 | .join(', '); 125 | 126 | result += scoped; 127 | } else { 128 | result += hostSelector; 129 | } 130 | 131 | index = cursor; 132 | } 133 | 134 | return result; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/services/LoggingService.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from '@/fw/di'; 2 | 3 | export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; 4 | 5 | export interface LogEntry { 6 | readonly id: string; 7 | readonly level: LogLevel; 8 | readonly message: string; 9 | readonly timestamp: number; 10 | readonly data?: unknown; 11 | } 12 | 13 | export type LogListener = (entry: LogEntry) => void; 14 | 15 | @injectable() 16 | export class LoggingService { 17 | private readonly logs: LogEntry[] = []; 18 | private readonly listeners = new Set(); 19 | private readonly maxLogs = 1000; // Keep only the last 1000 logs 20 | private logIdCounter = 0; 21 | private readonly enabledLevels = new Set(['info', 'warn', 'error']); 22 | 23 | /** 24 | * Log a debug message 25 | */ 26 | debug(message: string, data?: unknown): void { 27 | this.log('debug', message, data); 28 | } 29 | 30 | /** 31 | * Log an info message 32 | */ 33 | info(message: string, data?: unknown): void { 34 | this.log('info', message, data); 35 | } 36 | 37 | /** 38 | * Log a warning message 39 | */ 40 | warn(message: string, data?: unknown): void { 41 | this.log('warn', message, data); 42 | } 43 | 44 | /** 45 | * Log an error message 46 | */ 47 | error(message: string, data?: unknown): void { 48 | this.log('error', message, data); 49 | } 50 | 51 | /** 52 | * Internal log method 53 | */ 54 | private log(level: LogLevel, message: string, data?: unknown): void { 55 | if (!this.enabledLevels.has(level)) { 56 | return; 57 | } 58 | 59 | const entry: LogEntry = { 60 | id: `log-${this.logIdCounter++}`, 61 | level, 62 | message, 63 | timestamp: Date.now(), 64 | data, 65 | }; 66 | 67 | this.logs.push(entry); 68 | 69 | // Keep only the last maxLogs entries 70 | if (this.logs.length > this.maxLogs) { 71 | this.logs.splice(0, this.logs.length - this.maxLogs); 72 | } 73 | 74 | // Notify all listeners 75 | this.listeners.forEach(listener => listener(entry)); 76 | 77 | // Also log to console in development 78 | if (process.env.NODE_ENV === 'development') { 79 | const prefix = `[Pix3 ${level.toUpperCase()}] ${message}`; 80 | if (level === 'debug') { 81 | console.debug(prefix, data); 82 | } else if (level === 'info') { 83 | console.log(prefix, data); 84 | } else if (level === 'warn') { 85 | console.warn(prefix, data); 86 | } else if (level === 'error') { 87 | console.error(prefix, data); 88 | } 89 | } 90 | } 91 | 92 | /** 93 | * Get all logs 94 | */ 95 | getLogs(): LogEntry[] { 96 | return [...this.logs]; 97 | } 98 | 99 | /** 100 | * Get logs filtered by level 101 | */ 102 | getLogsByLevel(...levels: LogLevel[]): LogEntry[] { 103 | const levelSet = new Set(levels); 104 | return this.logs.filter(log => levelSet.has(log.level)); 105 | } 106 | 107 | /** 108 | * Clear all logs 109 | */ 110 | clearLogs(): void { 111 | this.logs.length = 0; 112 | this.logIdCounter = 0; 113 | } 114 | 115 | /** 116 | * Subscribe to log entries 117 | */ 118 | subscribe(listener: LogListener): () => void { 119 | this.listeners.add(listener); 120 | return () => { 121 | this.listeners.delete(listener); 122 | }; 123 | } 124 | 125 | /** 126 | * Set enabled log levels 127 | */ 128 | setEnabledLevels(levels: LogLevel[]): void { 129 | this.enabledLevels.clear(); 130 | levels.forEach(level => this.enabledLevels.add(level)); 131 | } 132 | 133 | /** 134 | * Get enabled log levels 135 | */ 136 | getEnabledLevels(): LogLevel[] { 137 | return Array.from(this.enabledLevels); 138 | } 139 | 140 | /** 141 | * Toggle a log level on/off 142 | */ 143 | toggleLevel(level: LogLevel): void { 144 | if (this.enabledLevels.has(level)) { 145 | this.enabledLevels.delete(level); 146 | } else { 147 | this.enabledLevels.add(level); 148 | } 149 | } 150 | 151 | /** 152 | * Check if a log level is enabled 153 | */ 154 | isLevelEnabled(level: LogLevel): boolean { 155 | return this.enabledLevels.has(level); 156 | } 157 | 158 | dispose(): void { 159 | this.listeners.clear(); 160 | this.logs.length = 0; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/features/scene/ReloadSceneOperation.ts: -------------------------------------------------------------------------------- 1 | import { ResourceManager } from '@/services/ResourceManager'; 2 | import { SceneManager } from '@/core/SceneManager'; 3 | import { SceneValidationError } from '@/core/SceneLoader'; 4 | import { ref } from 'valtio/vanilla'; 5 | import { getAppStateSnapshot } from '@/state'; 6 | import type { 7 | Operation, 8 | OperationContext, 9 | OperationInvokeResult, 10 | OperationMetadata, 11 | } from '@/core/Operation'; 12 | 13 | export interface ReloadSceneOperationParams { 14 | /** Scene ID to reload. */ 15 | sceneId: string; 16 | /** File path to reload from. */ 17 | filePath: string; 18 | } 19 | 20 | /** 21 | * ReloadSceneOperation reloads a scene from its file source. 22 | * Used when external file changes are detected. 23 | */ 24 | export class ReloadSceneOperation implements Operation { 25 | readonly metadata: OperationMetadata = { 26 | id: 'scene.reload', 27 | title: 'Reload Scene', 28 | description: 'Reload scene from file (triggered by external change)', 29 | }; 30 | 31 | private readonly params: ReloadSceneOperationParams; 32 | 33 | constructor(params: ReloadSceneOperationParams) { 34 | this.params = params; 35 | } 36 | 37 | async perform(context: OperationContext): Promise { 38 | const { state, container } = context; 39 | const { sceneId, filePath } = this.params; 40 | 41 | const resourceManager = container.getService( 42 | container.getOrCreateToken(ResourceManager) 43 | ); 44 | const sceneManager = container.getService( 45 | container.getOrCreateToken(SceneManager) 46 | ); 47 | 48 | try { 49 | // Read and parse the scene from file 50 | const sceneText = await resourceManager.readText(filePath); 51 | const graph = await sceneManager.parseScene(sceneText, { filePath }); 52 | 53 | // Get current scene descriptor 54 | const descriptor = state.scenes.descriptors[sceneId]; 55 | if (!descriptor) { 56 | throw new Error(`Scene descriptor not found: ${sceneId}`); 57 | } 58 | 59 | // Update scene manager with new graph 60 | sceneManager.setActiveSceneGraph(sceneId, graph); 61 | 62 | // Update state hierarchy 63 | state.scenes.hierarchies[sceneId] = { 64 | version: graph.version ?? null, 65 | description: graph.description ?? null, 66 | rootNodes: ref(graph.rootNodes), 67 | metadata: graph.metadata ?? {}, 68 | }; 69 | 70 | // Mark as not dirty since we just reloaded from source 71 | descriptor.isDirty = false; 72 | 73 | // Update modification time 74 | try { 75 | if (descriptor.fileHandle) { 76 | const file = await descriptor.fileHandle.getFile(); 77 | descriptor.lastModifiedTime = file.lastModified; 78 | } 79 | } catch (error) { 80 | console.debug('[ReloadSceneOperation] Could not update modification time:', error); 81 | } 82 | 83 | state.scenes.loadState = 'ready'; 84 | state.scenes.loadError = null; 85 | state.scenes.lastLoadedAt = Date.now(); 86 | 87 | const beforeSnapshot = context.snapshot; 88 | const afterSnapshot = getAppStateSnapshot(); 89 | 90 | return { 91 | didMutate: true, 92 | commit: { 93 | label: `Reload scene from file: ${filePath}`, 94 | beforeSnapshot, 95 | afterSnapshot, 96 | undo: () => { 97 | // For auto-reload, undo is not really applicable 98 | // Just restore the previous state snapshot 99 | Object.assign(state, beforeSnapshot); 100 | }, 101 | redo: () => { 102 | Object.assign(state, afterSnapshot); 103 | }, 104 | }, 105 | }; 106 | } catch (error) { 107 | let message = 'Failed to reload scene from file.'; 108 | if (error instanceof SceneValidationError) { 109 | message = `${message} Validation issues: ${error.details.join('; ')}`; 110 | } else if (error instanceof Error) { 111 | message = `${message} ${error.message}`; 112 | } 113 | state.scenes.loadState = 'error'; 114 | state.scenes.loadError = message; 115 | console.error('[ReloadSceneOperation] Reload failed:', error); 116 | throw error; 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/core/AssetLoader.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from '@/fw/di'; 2 | import { ResourceManager } from '@/services/ResourceManager'; 3 | import { MeshInstance } from '@/nodes/3D/MeshInstance'; 4 | import { NodeBase } from '@/nodes/NodeBase'; 5 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; 6 | import { AnimationClip } from 'three'; 7 | 8 | export interface AssetLoaderResult { 9 | node: NodeBase; 10 | } 11 | 12 | /** 13 | * AssetLoader is responsible for loading asset files from res:// or templ:// URLs 14 | * and converting them to concrete NodeBase instances in the scene tree. 15 | * 16 | * Supported formats: 17 | * - .glb / .gltf → MeshInstance 18 | * - (TODO) .png / .jpg → Sprite2D 19 | * - (TODO) .mp3 / .ogg → AudioNode 20 | */ 21 | @injectable() 22 | export class AssetLoader { 23 | @inject(ResourceManager) 24 | private readonly resources!: ResourceManager; 25 | 26 | /** 27 | * Load an asset file and return a NodeBase instance. 28 | * @param resourcePath res:// or templ:// path to the asset file 29 | * @param nodeId Optional node ID; generates UUID if not provided 30 | * @param nodeName Optional node name; defaults to asset filename 31 | * @returns Loaded asset as a NodeBase instance 32 | */ 33 | async loadAsset( 34 | resourcePath: string, 35 | nodeId?: string, 36 | nodeName?: string 37 | ): Promise { 38 | const extension = this.getExtension(resourcePath); 39 | 40 | switch (extension) { 41 | case 'glb': 42 | case 'gltf': 43 | return this.loadGltfAsMeshInstance(resourcePath, nodeId, nodeName); 44 | 45 | case 'png': 46 | case 'jpg': 47 | case 'jpeg': 48 | case 'webp': 49 | throw new Error(`[AssetLoader] Image loading not yet implemented: ${resourcePath}`); 50 | 51 | case 'mp3': 52 | case 'ogg': 53 | case 'wav': 54 | throw new Error(`[AssetLoader] Audio loading not yet implemented: ${resourcePath}`); 55 | 56 | default: 57 | throw new Error(`[AssetLoader] Unsupported asset type: ${extension}`); 58 | } 59 | } 60 | 61 | /** 62 | * Load a GLB/GLTF file and convert it to a MeshInstance node. 63 | * @param resourcePath res:// or templ:// path to the .glb/.gltf file 64 | * @param nodeId Optional node ID; generates UUID if not provided 65 | * @param nodeName Optional node name; defaults to 'mesh' if not provided 66 | * @returns MeshInstance node with loaded geometry and animations 67 | */ 68 | private async loadGltfAsMeshInstance( 69 | resourcePath: string, 70 | nodeId?: string, 71 | nodeName?: string 72 | ): Promise { 73 | try { 74 | const blob = await this.resources.readBlob(resourcePath); 75 | const arrayBuffer = await blob.arrayBuffer(); 76 | 77 | const loader = new GLTFLoader(); 78 | 79 | // Use parse() instead of loadAsync() with an empty resource path 80 | // This prevents GLTFLoader from trying to resolve external resources 81 | const gltf = await new Promise((resolve, reject) => { 82 | loader.parse( 83 | arrayBuffer, 84 | '', // Empty resource path - all data is embedded in GLB 85 | result => resolve(result), 86 | error => reject(error) 87 | ); 88 | }); 89 | 90 | const animations = gltf.animations.map((clip: AnimationClip) => clip.clone()); 91 | 92 | const finalNodeId = nodeId || crypto.randomUUID(); 93 | const finalNodeName = nodeName || 'mesh'; 94 | 95 | const meshInstance = new MeshInstance({ 96 | id: finalNodeId, 97 | name: finalNodeName, 98 | src: resourcePath, 99 | }); 100 | 101 | // Add loaded geometry to the instance 102 | meshInstance.add(gltf.scene); 103 | meshInstance.animations = animations; 104 | 105 | return { node: meshInstance }; 106 | } catch (error) { 107 | console.error(`[AssetLoader] Failed to load GLTF: ${resourcePath}`, error); 108 | throw new Error( 109 | `Failed to load asset: ${error instanceof Error ? error.message : String(error)}` 110 | ); 111 | } 112 | } 113 | 114 | /** 115 | * Extract file extension from resource path. 116 | */ 117 | private getExtension(resourcePath: string): string { 118 | const match = resourcePath.match(/\.([a-z0-9]+)$/i); 119 | return match ? match[1].toLowerCase() : ''; 120 | } 121 | 122 | dispose(): void { 123 | // No resources to clean up 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /docs/property-schema-quick-reference.md: -------------------------------------------------------------------------------- 1 | # Property Schema - Quick Reference 2 | 3 | ## For Node Class Authors 4 | 5 | ### Implement in Your Node Class 6 | 7 | ```typescript 8 | static getPropertySchema(): PropertySchema { 9 | const baseSchema = NodeBase.getPropertySchema(); // or Node2D, Node3D, etc. 10 | 11 | return { 12 | nodeType: 'YourNodeType', 13 | extends: 'ParentNodeType', 14 | properties: [ 15 | ...baseSchema.properties, 16 | // Add your properties here 17 | ], 18 | groups: { 19 | ...baseSchema.groups, 20 | // Define property groups 21 | }, 22 | }; 23 | } 24 | ``` 25 | 26 | ### Property Definition Template 27 | 28 | ```typescript 29 | { 30 | name: 'propertyName', 31 | type: 'number', // 'string' | 'number' | 'boolean' | 'vector2' | 'vector3' 32 | ui: { 33 | label: 'Display Name', 34 | group: 'CategoryName', 35 | description: 'What this does', 36 | step: 0.1, 37 | min: 0, 38 | max: 100, 39 | precision: 2, 40 | unit: '°', 41 | readOnly: false, 42 | hidden: false, 43 | }, 44 | getValue: (node) => (node as YourNodeType).propertyName, 45 | setValue: (node, value) => { 46 | (node as YourNodeType).propertyName = Number(value); 47 | }, 48 | } 49 | ``` 50 | 51 | ## Common Patterns 52 | 53 | ### Transform Property (2D Position) 54 | 55 | ```typescript 56 | { 57 | name: 'position.x', 58 | type: 'number', 59 | ui: { 60 | label: 'X', 61 | group: 'Transform', 62 | step: 0.01, 63 | precision: 2, 64 | }, 65 | getValue: (node) => (node as Node2D).position.x, 66 | setValue: (node, value) => { (node as Node2D).position.x = Number(value); }, 67 | } 68 | ``` 69 | 70 | ### Rotation (Degrees) 71 | 72 | ```typescript 73 | { 74 | name: 'rotation.z', 75 | type: 'number', 76 | ui: { 77 | label: 'Rotation', 78 | group: 'Transform', 79 | unit: '°', 80 | step: 0.1, 81 | precision: 1, 82 | }, 83 | getValue: (node) => (node as Node2D).rotation.z * (180 / Math.PI), 84 | setValue: (node, value) => { 85 | (node as Node2D).rotation.z = Number(value) * (Math.PI / 180); 86 | }, 87 | } 88 | ``` 89 | 90 | ### Boolean Property 91 | 92 | ```typescript 93 | { 94 | name: 'visible', 95 | type: 'boolean', 96 | ui: { 97 | label: 'Visible', 98 | group: 'Display', 99 | }, 100 | getValue: (node) => (node as NodeBase).visible, 101 | setValue: (node, value) => { (node as NodeBase).visible = Boolean(value); }, 102 | } 103 | ``` 104 | 105 | ### String Property 106 | 107 | ```typescript 108 | { 109 | name: 'texturePath', 110 | type: 'string', 111 | ui: { 112 | label: 'Texture', 113 | group: 'Rendering', 114 | description: 'Path to texture file', 115 | }, 116 | getValue: (node) => (node as Sprite2D).texturePath ?? '', 117 | setValue: (node, value) => { 118 | // For read-only, leave empty or handle via command 119 | }, 120 | } 121 | ``` 122 | 123 | ## Node Hierarchy 124 | 125 | ``` 126 | NodeBase 127 | ├── id (read-only) 128 | ├── name 129 | └── type (read-only) 130 | 131 | Node2D extends NodeBase 132 | ├── position.x, position.y 133 | ├── rotation.z (degrees) 134 | ├── scale.x, scale.y 135 | └── ...inherited from NodeBase 136 | 137 | Node3D extends NodeBase 138 | ├── position.x, position.y, position.z 139 | ├── rotation.x, rotation.y, rotation.z (degrees) 140 | ├── scale.x, scale.y, scale.z 141 | └── ...inherited from NodeBase 142 | 143 | Sprite2D extends Node2D 144 | ├── texturePath 145 | └── ...inherited from Node2D 146 | 147 | Group2D extends Node2D 148 | ├── width 149 | ├── height 150 | └── ...inherited from Node2D 151 | ``` 152 | 153 | ## Inspector Behavior 154 | 155 | 1. Inspector calls `getNodePropertySchema(primaryNode)` 156 | 2. Properties are grouped by `ui.group` 157 | 3. Groups are rendered in order: Base → others (alphabetical) 158 | 4. Each property renders based on `type`: 159 | - `'boolean'` → checkbox 160 | - `'number'` → number input with unit label 161 | - `'string'` → text input 162 | 5. User input → `UpdateObjectPropertyOperation` → OperationService 163 | 6. On error, UI reverts to previous value 164 | 165 | ## Tips 166 | 167 | - Use `group` to organize related properties 168 | - Set `precision` to control decimal display 169 | - Use `unit` for clarity (°, px, ms, etc.) 170 | - Mark important properties with `expanded: true` on groups 171 | - Use `step` to control input granularity 172 | - Read-only properties still appear for reference (set `readOnly: true`) 173 | - Hide technical properties with `hidden: true` 174 | -------------------------------------------------------------------------------- /src/sw.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { binaryTemplates, sceneTemplates } from './services/template-data'; 4 | 5 | declare const self: ServiceWorkerGlobalScope; 6 | 7 | const DB_NAME = 'pix3-db'; 8 | const STORE_NAME = 'handles'; 9 | const HANDLE_KEY = 'project-root'; 10 | 11 | async function getProjectHandle(): Promise { 12 | return new Promise((resolve, reject) => { 13 | const request = indexedDB.open(DB_NAME, 1); 14 | request.onupgradeneeded = event => { 15 | const db = (event.target as IDBOpenDBRequest).result; 16 | if (!db.objectStoreNames.contains(STORE_NAME)) { 17 | db.createObjectStore(STORE_NAME); 18 | } 19 | }; 20 | request.onsuccess = event => { 21 | const db = (event.target as IDBOpenDBRequest).result; 22 | const tx = db.transaction(STORE_NAME, 'readonly'); 23 | const store = tx.objectStore(STORE_NAME); 24 | const getReq = store.get(HANDLE_KEY); 25 | getReq.onsuccess = () => resolve(getReq.result as FileSystemDirectoryHandle); 26 | getReq.onerror = () => reject(getReq.error); 27 | }; 28 | request.onerror = () => reject(request.error); 29 | }); 30 | } 31 | 32 | async function getFileHandle( 33 | root: FileSystemDirectoryHandle, 34 | path: string 35 | ): Promise { 36 | const parts = path.split('/').filter(p => p.length > 0); 37 | let currentHandle: FileSystemDirectoryHandle | FileSystemFileHandle = root; 38 | 39 | for (let i = 0; i < parts.length; i++) { 40 | const part = parts[i]; 41 | if (currentHandle.kind !== 'directory') { 42 | throw new Error(`Path ${path} is invalid: ${parts[i - 1]} is not a directory`); 43 | } 44 | const dirHandle = currentHandle as FileSystemDirectoryHandle; 45 | if (i === parts.length - 1) { 46 | // Last part, try to get file 47 | try { 48 | return await dirHandle.getFileHandle(part); 49 | } catch { 50 | // If not found as file, maybe it's a directory? But we need a file. 51 | throw new Error(`File not found: ${path}`); 52 | } 53 | } else { 54 | // Intermediate part, must be directory 55 | currentHandle = await dirHandle.getDirectoryHandle(part); 56 | } 57 | } 58 | throw new Error(`Path ${path} resolves to a directory, not a file`); 59 | } 60 | 61 | async function handleResRequest(url: URL): Promise { 62 | try { 63 | const root = await getProjectHandle(); 64 | if (!root) { 65 | return new Response('Project not open', { status: 404 }); 66 | } 67 | 68 | // url.pathname includes the leading slash, e.g. /models/duck.glb 69 | const path = decodeURIComponent(url.pathname); 70 | const handle = await getFileHandle(root, path); 71 | const file = await handle.getFile(); 72 | return new Response(file, { 73 | headers: { 74 | 'Content-Type': file.type || 'application/octet-stream', 75 | 'Content-Length': file.size.toString(), 76 | }, 77 | }); 78 | } catch (error) { 79 | console.error('SW: Failed to serve res://', url.href, error); 80 | return new Response('File not found', { status: 404 }); 81 | } 82 | } 83 | 84 | async function handleTemplRequest(url: URL): Promise { 85 | // templ://Duck.glb -> pathname is //Duck.glb or /Duck.glb depending on browser 86 | // We strip leading slashes 87 | const id = decodeURIComponent(url.pathname).replace(/^\/+/, ''); 88 | 89 | // Check binary templates 90 | const binary = binaryTemplates.find(t => t.id === id); 91 | if (binary) { 92 | // Fetch the actual URL 93 | const response = await fetch(binary.url); 94 | return response; 95 | } 96 | 97 | // Check scene templates (return as text/json) 98 | const scene = sceneTemplates.find(t => t.id === id); 99 | if (scene) { 100 | return new Response(scene.contents, { 101 | headers: { 'Content-Type': 'text/yaml' }, // or application/json if it was json 102 | }); 103 | } 104 | 105 | return new Response('Template not found', { status: 404 }); 106 | } 107 | 108 | self.addEventListener('install', () => { 109 | self.skipWaiting(); 110 | }); 111 | 112 | self.addEventListener('activate', event => { 113 | event.waitUntil(self.clients.claim()); 114 | }); 115 | 116 | self.addEventListener('fetch', event => { 117 | const url = new URL(event.request.url); 118 | 119 | if (url.protocol === 'res:') { 120 | event.respondWith(handleResRequest(url)); 121 | return; 122 | } 123 | 124 | if (url.protocol === 'templ:') { 125 | event.respondWith(handleTemplRequest(url)); 126 | return; 127 | } 128 | }); 129 | -------------------------------------------------------------------------------- /src/ui/logs-view/logs-panel.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, inject, state, css, unsafeCSS } from '@/fw'; 2 | import { LoggingService, type LogLevel, type LogEntry } from '@/services/LoggingService'; 3 | import styles from './logs-panel.ts.css?raw'; 4 | 5 | @customElement('pix3-logs-panel') 6 | export class LogsPanel extends ComponentBase { 7 | static useShadowDom = true; 8 | 9 | static styles = css` 10 | ${unsafeCSS(styles)} 11 | `; 12 | 13 | @inject(LoggingService) 14 | private readonly loggingService!: LoggingService; 15 | 16 | @state() 17 | private logs: LogEntry[] = []; 18 | 19 | @state() 20 | private enabledLevels: Set = new Set(['info', 'warn', 'error']); 21 | 22 | private disposeListen?: () => void; 23 | private contentElement?: HTMLElement; 24 | 25 | connectedCallback() { 26 | super.connectedCallback(); 27 | 28 | // Get initial logs 29 | this.logs = this.loggingService.getLogs(); 30 | this.enabledLevels = new Set(this.loggingService.getEnabledLevels()); 31 | 32 | // Subscribe to new logs 33 | this.disposeListen = this.loggingService.subscribe(() => { 34 | this.logs = [...this.loggingService.getLogs()]; 35 | this.requestUpdate(); 36 | // Scroll to bottom when new log arrives 37 | this.scrollToBottom(); 38 | }); 39 | } 40 | 41 | disconnectedCallback() { 42 | this.disposeListen?.(); 43 | this.disposeListen = undefined; 44 | super.disconnectedCallback(); 45 | } 46 | 47 | protected updated() { 48 | // Scroll to bottom after render 49 | this.scrollToBottom(); 50 | } 51 | 52 | private scrollToBottom() { 53 | if (!this.contentElement) { 54 | this.contentElement = this.renderRoot.querySelector('.logs-content') || undefined; 55 | } 56 | if (this.contentElement) { 57 | this.contentElement.scrollTop = this.contentElement.scrollHeight; 58 | } 59 | } 60 | 61 | private handleLevelToggle(level: LogLevel) { 62 | this.loggingService.toggleLevel(level); 63 | this.enabledLevels = new Set(this.loggingService.getEnabledLevels()); 64 | this.requestUpdate(); 65 | } 66 | 67 | private handleClear() { 68 | this.loggingService.clearLogs(); 69 | this.logs = []; 70 | this.requestUpdate(); 71 | } 72 | 73 | private formatTime(timestamp: number): string { 74 | const date = new Date(timestamp); 75 | return date.toLocaleTimeString('en-US', { 76 | hour12: false, 77 | hour: '2-digit', 78 | minute: '2-digit', 79 | second: '2-digit', 80 | fractionalSecondDigits: 3, 81 | }); 82 | } 83 | 84 | private renderLevelToggle(level: LogLevel) { 85 | const isEnabled = this.enabledLevels.has(level); 86 | return html` 87 |
88 | { 93 | this.handleLevelToggle(level); 94 | }} 95 | aria-label="Toggle ${level} logs" 96 | /> 97 | 98 |
99 | `; 100 | } 101 | 102 | protected render() { 103 | const visibleLogs = this.logs.filter(log => this.enabledLevels.has(log.level)); 104 | 105 | return html` 106 |
107 |
108 |
109 | ${this.renderLevelToggle('debug')} ${this.renderLevelToggle('info')} 110 | ${this.renderLevelToggle('warn')} ${this.renderLevelToggle('error')} 111 |
112 | 115 |
116 |
117 | ${visibleLogs.length === 0 118 | ? html`
No logs to display
` 119 | : html` 120 |
    121 | ${visibleLogs.map( 122 | log => html` 123 |
  • 124 | ${log.level.toUpperCase()} 125 | ${log.message} 126 | ${this.formatTime(log.timestamp)} 127 |
  • 128 | ` 129 | )} 130 |
131 | `} 132 |
133 |
134 | `; 135 | } 136 | } 137 | 138 | declare global { 139 | interface HTMLElementTagNameMap { 140 | 'pix3-logs-panel': LogsPanel; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/ui/shared/pix3-panel.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, inject, property, state, css, unsafeCSS } from '@/fw'; 2 | import { ifDefined } from 'lit/directives/if-defined.js'; 3 | import { FocusRingService } from '@/services/FocusRingService'; 4 | import styles from './pix3-panel.ts.css?raw'; 5 | 6 | let panelCounter = 0; 7 | 8 | @customElement('pix3-panel') 9 | export class Pix3Panel extends ComponentBase { 10 | static useShadowDom = true; 11 | 12 | static styles = css` 13 | ${unsafeCSS(styles)} 14 | `; 15 | 16 | @property({ attribute: 'panel-title' }) 17 | title = ''; 18 | 19 | @property({ attribute: 'panel-description' }) 20 | description = ''; 21 | 22 | @property({ attribute: 'panel-role' }) 23 | panelRole: 'region' | 'form' | 'presentation' = 'region'; 24 | 25 | @property({ attribute: 'actions-label' }) 26 | actionsLabel = 'Panel actions'; 27 | 28 | @property({ attribute: 'accent', reflect: true }) 29 | accent: 'default' | 'primary' | 'warning' = 'default'; 30 | 31 | @state() 32 | private hasBodyContent = false; 33 | 34 | @inject(FocusRingService) 35 | private readonly focusRing!: FocusRingService; 36 | 37 | private cleanupActions?: () => void; 38 | private readonly instanceId = panelCounter++; 39 | 40 | private onSlotChange(): void { 41 | const slot = (this.renderRoot as any)?.querySelector?.('slot:not([name])'); 42 | if (!slot) { 43 | this.hasBodyContent = false; 44 | return; 45 | } 46 | const nodes = slot.assignedNodes({ flatten: true }).filter((node: Node) => { 47 | // Filter out whitespace-only text nodes 48 | return node.nodeType !== Node.TEXT_NODE || (node.textContent?.trim() ?? '').length > 0; 49 | }); 50 | const newHasBodyContent = nodes.length > 0; 51 | 52 | // Only update state if the value actually changed to avoid unnecessary renders 53 | if (newHasBodyContent !== this.hasBodyContent) { 54 | this.hasBodyContent = newHasBodyContent; 55 | } 56 | } 57 | 58 | disconnectedCallback(): void { 59 | this.cleanupActions?.(); 60 | this.cleanupActions = undefined; 61 | super.disconnectedCallback(); 62 | } 63 | 64 | protected firstUpdated(): void { 65 | this.ensureActionsFocusController(); 66 | } 67 | 68 | private ensureActionsFocusController(): void { 69 | if (this.cleanupActions) { 70 | return; 71 | } 72 | 73 | const root = (this.renderRoot as HTMLElement) ?? this; 74 | const actionsHost = root.querySelector('[data-panel-actions]'); 75 | if (!actionsHost) { 76 | return; 77 | } 78 | 79 | this.cleanupActions = this.focusRing.attachRovingFocus(actionsHost, { 80 | selector: 'pix3-toolbar-button:not([disabled])', 81 | orientation: 'horizontal', 82 | focusFirstOnInit: false, 83 | }); 84 | } 85 | 86 | protected render() { 87 | const headerId = `pix3-panel-${this.instanceId}-header`; 88 | const descriptionId = this.description 89 | ? `pix3-panel-${this.instanceId}-description` 90 | : undefined; 91 | 92 | const ariaDescribedBy = descriptionId ? descriptionId : undefined; 93 | const hasHeader = this.title || (this.renderRoot as any)?.querySelector?.('[slot="actions"]'); 94 | 95 | return html` 96 |
102 | 103 | ${hasHeader 104 | ? html`
105 |
106 | ${this.title} 107 | 108 |
109 | 117 |
` 118 | : null} 119 | ${this.description && !this.hasBodyContent 120 | ? html`

121 | ${this.description} 122 |

` 123 | : null} 124 |
125 | 126 |
127 |
128 | 129 |
130 |
131 | `; 132 | } 133 | } 134 | 135 | declare global { 136 | interface HTMLElementTagNameMap { 137 | 'pix3-panel': Pix3Panel; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/features/scene/CreateBoxOperation.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Operation, 3 | OperationContext, 4 | OperationInvokeResult, 5 | OperationMetadata, 6 | } from '@/core/Operation'; 7 | import { GeometryMesh } from '@/nodes/3D/GeometryMesh'; 8 | import { SceneManager } from '@/core/SceneManager'; 9 | import { ref } from 'valtio/vanilla'; 10 | 11 | export interface CreateBoxOperationParams { 12 | boxName?: string; 13 | size?: [number, number, number]; 14 | color?: string; 15 | } 16 | 17 | export class CreateBoxOperation implements Operation { 18 | readonly metadata: OperationMetadata = { 19 | id: 'scene.create-box', 20 | title: 'Create Box', 21 | description: 'Create a box geometry mesh in the scene', 22 | tags: ['scene', 'geometry', 'box', 'node'], 23 | affectsNodeStructure: true, 24 | }; 25 | 26 | private readonly params: CreateBoxOperationParams; 27 | 28 | constructor(params: CreateBoxOperationParams = {}) { 29 | this.params = params; 30 | } 31 | 32 | async perform(context: OperationContext): Promise { 33 | const { state, container } = context; 34 | const activeSceneId = state.scenes.activeSceneId; 35 | 36 | if (!activeSceneId) { 37 | return { didMutate: false }; 38 | } 39 | 40 | const sceneManager = container.getService( 41 | container.getOrCreateToken(SceneManager) 42 | ); 43 | const sceneGraph = sceneManager.getSceneGraph(activeSceneId); 44 | if (!sceneGraph) { 45 | return { didMutate: false }; 46 | } 47 | 48 | // Generate a unique node ID 49 | const nodeId = `box-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; 50 | 51 | // Create the box node 52 | const boxName = this.params.boxName || 'Box'; 53 | const size = this.params.size ?? [1, 1, 1]; 54 | const color = this.params.color ?? '#4e8df5'; 55 | 56 | const node = new GeometryMesh({ 57 | id: nodeId, 58 | name: boxName, 59 | geometry: 'box', 60 | size, 61 | material: { color }, 62 | }); 63 | 64 | // Add to the scene graph 65 | sceneGraph.rootNodes.push(node); 66 | sceneGraph.nodeMap.set(nodeId, node); 67 | 68 | // Update the state hierarchy - REPLACE the entire object to trigger reactivity 69 | const hierarchy = state.scenes.hierarchies[activeSceneId]; 70 | if (hierarchy) { 71 | // Create a new hierarchy state object to trigger Valtio subscribers 72 | state.scenes.hierarchies[activeSceneId] = { 73 | version: hierarchy.version, 74 | description: hierarchy.description, 75 | rootNodes: ref([...sceneGraph.rootNodes]), // Create new array reference 76 | metadata: hierarchy.metadata, 77 | }; 78 | } 79 | 80 | // Mark scene as dirty 81 | const descriptor = state.scenes.descriptors[activeSceneId]; 82 | if (descriptor) { 83 | descriptor.isDirty = true; 84 | } 85 | 86 | // Select the newly created node 87 | state.selection.nodeIds = [nodeId]; 88 | state.selection.primaryNodeId = nodeId; 89 | 90 | return { 91 | didMutate: true, 92 | commit: { 93 | label: `Create ${boxName}`, 94 | undo: () => { 95 | // Remove from scene graph 96 | sceneGraph.rootNodes = sceneGraph.rootNodes.filter(n => n.nodeId !== nodeId); 97 | sceneGraph.nodeMap.delete(nodeId); 98 | 99 | // Dispose the node 100 | node.dispose(); 101 | 102 | // Update state hierarchy 103 | const hierarchy = state.scenes.hierarchies[activeSceneId]; 104 | if (hierarchy) { 105 | state.scenes.hierarchies[activeSceneId] = { 106 | version: hierarchy.version, 107 | description: hierarchy.description, 108 | rootNodes: ref([...sceneGraph.rootNodes]), 109 | metadata: hierarchy.metadata, 110 | }; 111 | } 112 | 113 | // Clear selection 114 | state.selection.nodeIds = []; 115 | state.selection.primaryNodeId = null; 116 | }, 117 | redo: async () => { 118 | // Re-add to scene graph 119 | sceneGraph.rootNodes.push(node); 120 | sceneGraph.nodeMap.set(nodeId, node); 121 | 122 | // Update state hierarchy 123 | const hierarchy = state.scenes.hierarchies[activeSceneId]; 124 | if (hierarchy) { 125 | state.scenes.hierarchies[activeSceneId] = { 126 | version: hierarchy.version, 127 | description: hierarchy.description, 128 | rootNodes: ref([...sceneGraph.rootNodes]), 129 | metadata: hierarchy.metadata, 130 | }; 131 | } 132 | 133 | // Restore selection 134 | state.selection.nodeIds = [nodeId]; 135 | state.selection.primaryNodeId = nodeId; 136 | }, 137 | }, 138 | }; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/fw/di.ts: -------------------------------------------------------------------------------- 1 | // Service lifetime types 2 | export const ServiceLifetime = { 3 | Singleton: 'singleton', 4 | Transient: 'transient', 5 | } as const; 6 | export type ServiceLifetimeOption = (typeof ServiceLifetime)[keyof typeof ServiceLifetime]; 7 | 8 | // Base service interface 9 | export interface IService { 10 | dispose?(): void; 11 | } 12 | 13 | // Service descriptor interface 14 | interface ServiceDescriptor { 15 | token: symbol; 16 | implementation: new () => T; 17 | lifetime: ServiceLifetimeOption; 18 | } 19 | 20 | // Main container class 21 | export class ServiceContainer { 22 | private static instance: ServiceContainer; 23 | private services = new Map(); 24 | private singletonInstances = new Map(); 25 | private tokenRegistry = new Map(); // New token registry 26 | 27 | static getInstance(): ServiceContainer { 28 | if (!ServiceContainer.instance) { 29 | ServiceContainer.instance = new ServiceContainer(); 30 | } 31 | return ServiceContainer.instance; 32 | } 33 | 34 | // Register a service 35 | addService(token: symbol, implementation: new () => T, lifetime: ServiceLifetimeOption) { 36 | // If a service is re-registered, remove any existing cached singleton instance so 37 | // tests and runtime can replace implementations without stale instances. 38 | if (this.singletonInstances.has(token)) { 39 | const existing = this.singletonInstances.get(token) as IService | undefined; 40 | try { 41 | existing?.dispose?.(); 42 | } catch { 43 | // swallow disposal errors during re-registration 44 | } 45 | this.singletonInstances.delete(token); 46 | } 47 | 48 | this.services.set(token, { token, implementation, lifetime }); 49 | } 50 | 51 | // Retrieve an existing token or create a new one 52 | getOrCreateToken(service: symbol | string | (new () => unknown)): symbol { 53 | if (typeof service === 'symbol') { 54 | return service; 55 | } 56 | 57 | const serviceName = typeof service === 'string' ? service : service?.name; 58 | if (!serviceName) { 59 | throw new Error( 60 | 'Cannot derive service name for DI token. Provide a class, string, or symbol.' 61 | ); 62 | } 63 | 64 | if (!this.tokenRegistry.has(serviceName)) { 65 | this.tokenRegistry.set(serviceName, Symbol(serviceName)); 66 | } 67 | return this.tokenRegistry.get(serviceName)!; 68 | } 69 | 70 | // Get service instance 71 | getService(token: symbol): T { 72 | const descriptor = this.services.get(token) as ServiceDescriptor | undefined; 73 | 74 | if (!descriptor) { 75 | throw new Error(`Service not registered for token: ${token.toString()}`); 76 | } 77 | 78 | if (descriptor.lifetime === ServiceLifetime.Singleton) { 79 | return this.getSingletonInstance(descriptor) as T; 80 | } 81 | 82 | if (descriptor.lifetime === ServiceLifetime.Transient) { 83 | return new descriptor.implementation() as T; 84 | } 85 | 86 | throw new Error(`Unsupported lifetime: ${descriptor.lifetime}`); 87 | } 88 | 89 | private getSingletonInstance(descriptor: ServiceDescriptor): T { 90 | if (!this.singletonInstances.has(descriptor.token)) { 91 | this.singletonInstances.set(descriptor.token, new descriptor.implementation()); 92 | } 93 | return this.singletonInstances.get(descriptor.token) as T; 94 | } 95 | } 96 | 97 | // Service decorator (similar to @Injectable in Blazor) 98 | export function injectable(lifetime: ServiceLifetimeOption = ServiceLifetime.Singleton) { 99 | return function (target: new () => T) { 100 | const container = ServiceContainer.getInstance(); 101 | const token = container.getOrCreateToken(target); 102 | container.addService(token, target, lifetime); 103 | return target; 104 | }; 105 | } 106 | 107 | // Inject decorator (auto-detects type if not provided) 108 | import 'reflect-metadata'; 109 | 110 | export function inject(serviceType?: new () => T) { 111 | return function (target: object, propertyKey: string | symbol) { 112 | // If no explicit type, use reflect-metadata to get the property type 113 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 114 | const type = serviceType || (Reflect as any).getMetadata('design:type', target, propertyKey); 115 | if (!type) { 116 | throw new Error( 117 | `Cannot resolve type for property '${String(propertyKey)}'. Make sure emitDecoratorMetadata is enabled.` 118 | ); 119 | } 120 | const container = ServiceContainer.getInstance(); 121 | const token = container.getOrCreateToken(type); 122 | const descriptor = { 123 | get: function (this: object) { 124 | return container.getService(token); 125 | }, 126 | enumerable: true, 127 | configurable: true, 128 | }; 129 | Object.defineProperty(target, propertyKey, descriptor); 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /src/ui/assets-browser/asset-tree.ts.css: -------------------------------------------------------------------------------- 1 | asset-tree { 2 | display: block; 3 | height: 100%; 4 | } 5 | 6 | .tree { 7 | height: 100%; 8 | overflow: auto; 9 | min-height: 0; /* allow flex children to shrink */ 10 | display: block; 11 | padding-right: 0; 12 | box-sizing: border-box; 13 | } 14 | 15 | .tree-node { 16 | display: block; 17 | } 18 | 19 | .node-children { 20 | padding-inline-start: 1.1rem; 21 | margin: 0; 22 | } 23 | 24 | .node-row { 25 | display: flex; 26 | align-items: center; 27 | gap: 0.5rem; 28 | padding: 0.25rem 0.5rem; 29 | border-radius: 0.25rem; 30 | cursor: pointer; 31 | user-select: none; 32 | min-width: 0; 33 | } 34 | 35 | .icon { 36 | width: 18px; 37 | height: 18px; 38 | min-width: 18px; 39 | min-height: 18px; 40 | display: inline-flex; 41 | align-items: center; 42 | justify-content: center; 43 | color: rgba(245, 247, 250, 0.9); 44 | flex-shrink: 0; 45 | } 46 | 47 | .icon svg { 48 | width: 18px; 49 | height: 18px; 50 | display: block; 51 | } 52 | 53 | .icon.folder { 54 | /* Windows Explorer like folder yellow */ 55 | color: #f2c94c; 56 | } 57 | 58 | /* .icon.folder svg path left intentionally empty to allow future fine-tuning of folder svg strokes */ 59 | 60 | .expander svg, 61 | .expander-placeholder svg { 62 | width: 1rem; 63 | height: 1rem; 64 | display: block; 65 | } 66 | 67 | .node-row:hover { 68 | background: rgba(255, 255, 255, 0.02); 69 | } 70 | 71 | .node-row.selected { 72 | background: rgba(255, 207, 51, 0.14); 73 | outline: 1px solid rgba(255, 207, 51, 0.18); 74 | } 75 | 76 | .node-row.drag-over { 77 | background: rgba(76, 175, 80, 0.2); 78 | outline: 2px solid rgba(76, 175, 80, 0.5); 79 | } 80 | 81 | .tree.drag-over-root { 82 | background: rgba(76, 175, 80, 0.1); 83 | outline: 2px dashed rgba(76, 175, 80, 0.4); 84 | outline-offset: -2px; 85 | } 86 | 87 | .expander { 88 | background: none; 89 | border: none; 90 | color: inherit; 91 | cursor: pointer; 92 | width: 1.1rem; 93 | text-align: center; 94 | padding: 0; 95 | } 96 | 97 | .caret { 98 | width: 0.9rem; 99 | height: 0.9rem; 100 | display: inline-block; 101 | transform-origin: 50% 50%; 102 | transition: transform 120ms ease-in-out; 103 | } 104 | 105 | .expander[data-expanded='true'] .caret { 106 | transform: rotate(90deg); 107 | } 108 | 109 | .expander:focus { 110 | outline: none; 111 | box-shadow: 0 0 0 2px rgba(255, 207, 51, 0.6); 112 | } 113 | 114 | .expander-placeholder { 115 | display: inline-block; 116 | width: 1.1rem; 117 | } 118 | 119 | .node-name { 120 | flex: 1 1 auto; 121 | font-size: 0.9rem; 122 | white-space: nowrap; 123 | overflow: hidden; 124 | text-overflow: ellipsis; 125 | min-width: 0; 126 | } 127 | 128 | .node-kind { 129 | color: rgba(255, 255, 255, 0.5); 130 | font-size: 0.78rem; 131 | margin-left: 0.5rem; 132 | } 133 | 134 | .empty { 135 | margin: 0; 136 | color: rgba(245, 247, 250, 0.6); 137 | font-style: italic; 138 | padding: 0.5rem 0.25rem; 139 | } 140 | 141 | /* Toolbar */ 142 | .asset-tree-root { 143 | display: flex; 144 | flex-direction: column; 145 | height: 100%; 146 | } 147 | 148 | .toolbar { 149 | display: flex; 150 | gap: 0.4rem; 151 | align-items: center; 152 | padding: 0.25rem; 153 | border-bottom: 1px solid rgba(255, 255, 255, 0.02); 154 | background: linear-gradient(180deg, rgba(255, 255, 255, 0.01), transparent); 155 | box-sizing: border-box; 156 | width: 100%; 157 | } 158 | 159 | .tb-btn { 160 | background: none; 161 | border: none; 162 | color: inherit; 163 | width: 28px; 164 | height: 28px; 165 | display: inline-flex; 166 | align-items: center; 167 | justify-content: center; 168 | padding: 2px; 169 | border-radius: 4px; 170 | cursor: pointer; 171 | } 172 | 173 | .tb-btn:hover { 174 | background: rgba(255, 255, 255, 0.02); 175 | } 176 | 177 | .tb-dropdown { 178 | position: relative; 179 | } 180 | 181 | .small-caret { 182 | width: 10px; 183 | height: 10px; 184 | margin-left: 4px; 185 | } 186 | 187 | .tb-icon { 188 | display: inline-flex; 189 | width: 18px; 190 | height: 18px; 191 | align-items: center; 192 | justify-content: center; 193 | } 194 | 195 | /* File icons in feather are visually narrower; scale slightly to match folder */ 196 | .tb-icon.file svg { 197 | transform: scale(1.05); 198 | transform-origin: 50% 50%; 199 | } 200 | 201 | .menu { 202 | position: absolute; 203 | top: 34px; 204 | left: 0; 205 | background: #15171a; 206 | border: 1px solid rgba(255, 255, 255, 0.03); 207 | box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6); 208 | padding: 0.25rem; 209 | border-radius: 6px; 210 | min-width: 120px; 211 | z-index: 10; 212 | } 213 | 214 | .menu-item { 215 | display: block; 216 | width: 100%; 217 | background: none; 218 | border: none; 219 | color: rgba(245, 247, 250, 0.95); 220 | text-align: left; 221 | padding: 0.35rem 0.5rem; 222 | border-radius: 4px; 223 | cursor: pointer; 224 | font-size: 0.9rem; 225 | } 226 | 227 | .menu-item:hover { 228 | background: rgba(255, 255, 255, 0.02); 229 | } 230 | -------------------------------------------------------------------------------- /src/features/selection/SelectObjectOperation.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Operation, 3 | OperationContext, 4 | OperationInvokeResult, 5 | OperationMetadata, 6 | } from '@/core/Operation'; 7 | import type { AppStateSnapshot } from '@/state'; 8 | 9 | export interface SelectObjectParams { 10 | nodeId: string | null; 11 | additive?: boolean; 12 | range?: boolean; 13 | makePrimary?: boolean; 14 | } 15 | 16 | export class SelectObjectOperation implements Operation { 17 | readonly metadata: OperationMetadata; 18 | private readonly params: SelectObjectParams; 19 | 20 | constructor(params: SelectObjectParams) { 21 | this.params = params; 22 | this.metadata = { 23 | id: 'scene.select-object', 24 | title: 'Select Object', 25 | description: 'Select one or more objects in the scene hierarchy', 26 | tags: ['selection'], 27 | coalesceKey: undefined, 28 | }; 29 | } 30 | 31 | async perform(context: OperationContext): Promise { 32 | const { state, snapshot } = context; 33 | const { nodeId, additive = false, range = false, makePrimary = false } = this.params; 34 | 35 | const prevNodeIds = [...snapshot.selection.nodeIds]; 36 | const prevPrimaryId = snapshot.selection.primaryNodeId; 37 | 38 | const { newNodeIds, newPrimaryNodeId } = this.computeSelection(snapshot, { 39 | nodeId, 40 | additive, 41 | range, 42 | makePrimary, 43 | }); 44 | 45 | if ( 46 | prevPrimaryId === newPrimaryNodeId && 47 | prevNodeIds.length === newNodeIds.length && 48 | prevNodeIds.every((id, i) => id === newNodeIds[i]) 49 | ) { 50 | return { didMutate: false }; 51 | } 52 | 53 | state.selection.nodeIds = newNodeIds; 54 | state.selection.primaryNodeId = newPrimaryNodeId; 55 | 56 | return { 57 | didMutate: true, 58 | commit: { 59 | label: 'Select Object', 60 | beforeSnapshot: context.snapshot, 61 | undo: async () => { 62 | state.selection.nodeIds = [...prevNodeIds]; 63 | state.selection.primaryNodeId = prevPrimaryId; 64 | }, 65 | redo: async () => { 66 | state.selection.nodeIds = [...newNodeIds]; 67 | state.selection.primaryNodeId = newPrimaryNodeId; 68 | }, 69 | }, 70 | }; 71 | } 72 | 73 | private computeSelection( 74 | snapshot: AppStateSnapshot, 75 | opts: Required> & { nodeId: string | null } 76 | ): { newNodeIds: string[]; newPrimaryNodeId: string | null } { 77 | const { nodeId, additive, range, makePrimary } = opts; 78 | 79 | if (nodeId === null) { 80 | return { newNodeIds: [], newPrimaryNodeId: null }; 81 | } 82 | 83 | if (range && snapshot.selection.primaryNodeId) { 84 | const sceneHierarchy = this.getActiveSceneHierarchy(snapshot); 85 | if (sceneHierarchy) { 86 | const allNodeIds = this.collectAllNodeIds(sceneHierarchy.rootNodes as any[]); 87 | const primaryIndex = allNodeIds.indexOf(snapshot.selection.primaryNodeId); 88 | const targetIndex = allNodeIds.indexOf(nodeId); 89 | 90 | if (primaryIndex !== -1 && targetIndex !== -1) { 91 | const startIndex = Math.min(primaryIndex, targetIndex); 92 | const endIndex = Math.max(primaryIndex, targetIndex); 93 | const selection = allNodeIds.slice(startIndex, endIndex + 1); 94 | return { newNodeIds: selection, newPrimaryNodeId: snapshot.selection.primaryNodeId }; 95 | } 96 | } 97 | return { newNodeIds: [nodeId], newPrimaryNodeId: nodeId }; 98 | } 99 | 100 | if (additive) { 101 | const current = new Set(snapshot.selection.nodeIds); 102 | if (current.has(nodeId)) { 103 | current.delete(nodeId); 104 | const ids = Array.from(current); 105 | const newPrimary = 106 | snapshot.selection.primaryNodeId === nodeId 107 | ? ids.length > 0 108 | ? ids[0] 109 | : null 110 | : snapshot.selection.primaryNodeId; 111 | return { newNodeIds: ids, newPrimaryNodeId: newPrimary }; 112 | } 113 | current.add(nodeId); 114 | const ids = Array.from(current); 115 | const newPrimary = 116 | makePrimary || !snapshot.selection.primaryNodeId 117 | ? nodeId 118 | : snapshot.selection.primaryNodeId; 119 | return { newNodeIds: ids, newPrimaryNodeId: newPrimary }; 120 | } 121 | 122 | return { newNodeIds: [nodeId], newPrimaryNodeId: nodeId }; 123 | } 124 | 125 | private getActiveSceneHierarchy(snapshot: AppStateSnapshot) { 126 | const activeSceneId = snapshot.scenes.activeSceneId; 127 | return activeSceneId ? snapshot.scenes.hierarchies[activeSceneId] : null; 128 | } 129 | 130 | private collectAllNodeIds(nodes: readonly any[]): string[] { 131 | const result: string[] = []; 132 | const collect = (list: readonly any[]) => { 133 | for (const node of list) { 134 | result.push(node.nodeId || node.id); 135 | if (node.children?.length) collect(node.children); 136 | } 137 | }; 138 | collect(nodes); 139 | return result; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/fw/property-schema.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Property Schema Framework 3 | * 4 | * Defines metadata for node properties to enable dynamic inspector UI generation. 5 | * Similar to Godot's property system - each node class exposes its editable properties 6 | * with type information, validation rules, and UI hints. 7 | */ 8 | 9 | /** Supported property types for editor UI generation */ 10 | export type PropertyType = 11 | | 'string' 12 | | 'number' 13 | | 'boolean' 14 | | 'vector2' 15 | | 'vector3' 16 | | 'vector4' 17 | | 'euler' 18 | | 'color' 19 | | 'enum' 20 | | 'select' 21 | | 'object'; 22 | 23 | /** UI hints for better editor presentation */ 24 | export interface PropertyUIHints { 25 | /** Display label for the property */ 26 | label?: string; 27 | 28 | /** Longer description shown as tooltip */ 29 | description?: string; 30 | 31 | /** Group/category for organizing properties (e.g., 'Transform', 'Rendering') */ 32 | group?: string; 33 | 34 | /** Minimum value for numbers */ 35 | min?: number; 36 | 37 | /** Maximum value for numbers */ 38 | max?: number; 39 | 40 | /** Step increment for number sliders */ 41 | step?: number; 42 | 43 | /** Unit label (e.g., '°' for degrees, 'px' for pixels) */ 44 | unit?: string; 45 | 46 | /** For enum/select - array of allowed values */ 47 | options?: string[] | Record; 48 | 49 | /** Number of decimal places to display */ 50 | precision?: number; 51 | 52 | /** Show as slider instead of input field */ 53 | slider?: boolean; 54 | 55 | /** Color format: 'hex', 'rgb', 'rgba' */ 56 | colorFormat?: 'hex' | 'rgb' | 'rgba'; 57 | 58 | /** Show expanded by default in inspector */ 59 | expanded?: boolean; 60 | 61 | /** Hide from inspector */ 62 | hidden?: boolean; 63 | 64 | /** Property is read-only */ 65 | readOnly?: boolean; 66 | } 67 | 68 | /** Validation rule for a property */ 69 | export interface PropertyValidation { 70 | /** Validate the value before update */ 71 | validate: (value: unknown) => boolean | string; // true/false or error message 72 | 73 | /** Transform value if validation passes */ 74 | transform?: (value: unknown) => unknown; 75 | } 76 | 77 | /** Definition for a single property that can be edited in the inspector */ 78 | export interface PropertyDefinition { 79 | /** Property name/key */ 80 | name: string; 81 | 82 | /** TypeScript type name (for documentation) */ 83 | type: PropertyType; 84 | 85 | /** UI hints and display settings */ 86 | ui?: PropertyUIHints; 87 | 88 | /** Optional validation and transformation */ 89 | validation?: PropertyValidation; 90 | 91 | /** Default value if not set */ 92 | defaultValue?: unknown; 93 | 94 | /** Get the current value from the node */ 95 | getValue: (node: unknown) => unknown; 96 | 97 | /** Set the value on the node */ 98 | setValue: (node: unknown, value: unknown) => void; 99 | } 100 | 101 | /** Complete schema for a node class */ 102 | export interface PropertySchema { 103 | /** Node type name */ 104 | nodeType: string; 105 | 106 | /** Base class name (if extending another) */ 107 | extends?: string; 108 | 109 | /** All editable properties */ 110 | properties: PropertyDefinition[]; 111 | 112 | /** Group definitions for organizing properties */ 113 | groups?: Record< 114 | string, 115 | { 116 | label: string; 117 | description?: string; 118 | expanded?: boolean; 119 | } 120 | >; 121 | } 122 | 123 | /** 124 | * Helper to create a property definition with sensible defaults. 125 | */ 126 | export function defineProperty( 127 | name: string, 128 | type: PropertyType, 129 | config: Partial> & { 130 | getValue: (node: unknown) => unknown; 131 | setValue: (node: unknown, value: unknown) => void; 132 | } 133 | ): PropertyDefinition { 134 | return { 135 | name, 136 | type, 137 | ...config, 138 | }; 139 | } 140 | 141 | /** 142 | * Helper to create a group definition. 143 | */ 144 | export function defineGroup( 145 | name: string, 146 | label: string, 147 | options?: { description?: string; expanded?: boolean } 148 | ) { 149 | return { 150 | [name]: { 151 | label, 152 | ...options, 153 | }, 154 | }; 155 | } 156 | 157 | /** 158 | * Merge schemas when extending property definitions. 159 | */ 160 | export function mergeSchemas(base: PropertySchema, extended: PropertySchema): PropertySchema { 161 | return { 162 | nodeType: extended.nodeType, 163 | extends: extended.extends || base.nodeType, 164 | properties: [...base.properties, ...extended.properties], 165 | groups: { 166 | ...base.groups, 167 | ...extended.groups, 168 | }, 169 | }; 170 | } 171 | 172 | /** Vector2 value helper */ 173 | export interface Vector2Value { 174 | x: number; 175 | y: number; 176 | } 177 | 178 | /** Vector3 value helper */ 179 | export interface Vector3Value { 180 | x: number; 181 | y: number; 182 | z: number; 183 | } 184 | 185 | /** Euler rotation value helper (in radians) */ 186 | export interface EulerValue { 187 | x: number; // pitch 188 | y: number; // yaw 189 | z: number; // roll 190 | order: string; 191 | } 192 | -------------------------------------------------------------------------------- /src/features/properties/UpdateObjectPropertyOperation.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Operation, 3 | OperationContext, 4 | OperationInvokeResult, 5 | OperationMetadata, 6 | } from '@/core/Operation'; 7 | import { NodeBase } from '@/nodes/NodeBase'; 8 | import { SceneManager } from '@/core/SceneManager'; 9 | import { ViewportRendererService } from '@/services/ViewportRenderService'; 10 | import { getNodePropertySchema } from '@/fw/property-schema-utils'; 11 | import type { PropertyDefinition } from '@/fw'; 12 | 13 | export interface UpdateObjectPropertyParams { 14 | nodeId: string; 15 | propertyPath: string; 16 | value: unknown; 17 | } 18 | 19 | export class UpdateObjectPropertyOperation implements Operation { 20 | readonly metadata: OperationMetadata = { 21 | id: 'scene.update-object-property', 22 | title: 'Update Object Property', 23 | description: 'Update a property on a scene object', 24 | tags: ['property', 'transform'], 25 | }; 26 | 27 | private readonly params: UpdateObjectPropertyParams; 28 | 29 | constructor(params: UpdateObjectPropertyParams) { 30 | this.params = params; 31 | } 32 | 33 | async perform(context: OperationContext): Promise { 34 | const { container, state } = context; 35 | const { nodeId, propertyPath, value } = this.params; 36 | 37 | const sceneManager = container.getService( 38 | container.getOrCreateToken(SceneManager) 39 | ); 40 | const sceneGraph = sceneManager.getActiveSceneGraph(); 41 | if (!sceneGraph) { 42 | return { didMutate: false }; 43 | } 44 | 45 | const node = sceneGraph.nodeMap.get(nodeId); 46 | if (!node) { 47 | return { didMutate: false }; 48 | } 49 | 50 | // Get property schema and definition 51 | const schema = getNodePropertySchema(node); 52 | const propDef = schema.properties.find(p => p.name === propertyPath); 53 | if (!propDef) { 54 | return { didMutate: false }; 55 | } 56 | 57 | const validation = this.validatePropertyUpdate(node, propDef, value); 58 | if (!validation.isValid) { 59 | console.warn('[UpdateObjectPropertyOperation] Validation failed:', validation.reason); 60 | return { didMutate: false }; 61 | } 62 | 63 | const previousValue = propDef.getValue(node); 64 | // Compare values as JSON strings to handle objects 65 | if (JSON.stringify(previousValue) === JSON.stringify(value)) { 66 | return { didMutate: false }; 67 | } 68 | 69 | // Set the property value using the schema's setValue method 70 | propDef.setValue(node, value); 71 | 72 | const activeSceneId = state.scenes.activeSceneId; 73 | if (activeSceneId) { 74 | state.scenes.lastLoadedAt = Date.now(); 75 | const descriptor = state.scenes.descriptors[activeSceneId]; 76 | if (descriptor) descriptor.isDirty = true; 77 | } 78 | 79 | // Trigger viewport updates 80 | this.updateViewport(container, propertyPath, node); 81 | 82 | return { 83 | didMutate: true, 84 | commit: { 85 | label: `Update ${propDef.ui?.label || propertyPath}`, 86 | beforeSnapshot: context.snapshot, 87 | undo: async () => { 88 | propDef.setValue(node, previousValue); 89 | if (activeSceneId) { 90 | state.scenes.lastLoadedAt = Date.now(); 91 | const descriptor = state.scenes.descriptors[activeSceneId]; 92 | if (descriptor) descriptor.isDirty = true; 93 | } 94 | this.updateViewport(container, propertyPath, node); 95 | }, 96 | redo: async () => { 97 | propDef.setValue(node, value); 98 | if (activeSceneId) { 99 | state.scenes.lastLoadedAt = Date.now(); 100 | const descriptor = state.scenes.descriptors[activeSceneId]; 101 | if (descriptor) descriptor.isDirty = true; 102 | } 103 | this.updateViewport(container, propertyPath, node); 104 | }, 105 | }, 106 | }; 107 | } 108 | 109 | private updateViewport(container: any, propertyPath: string, node: NodeBase) { 110 | try { 111 | const vr = container.getService( 112 | container.getOrCreateToken(ViewportRendererService) 113 | ) as ViewportRendererService; 114 | const isTransform = this.isTransformProperty(propertyPath); 115 | if (isTransform) { 116 | vr.updateNodeTransform(node); 117 | } else if (propertyPath === 'visible') { 118 | vr.updateNodeVisibility(node); 119 | } else { 120 | vr.updateSelection(); 121 | } 122 | } catch { 123 | // Silently ignore viewport renderer errors 124 | } 125 | } 126 | 127 | private isTransformProperty(propertyPath: string): boolean { 128 | return ['position', 'rotation', 'scale'].includes(propertyPath); 129 | } 130 | 131 | private validatePropertyUpdate( 132 | _node: NodeBase, 133 | _propDef: PropertyDefinition, 134 | value: unknown 135 | ): { isValid: boolean; reason?: string } { 136 | if (value === null || value === undefined) { 137 | return { isValid: false, reason: 'Value cannot be null or undefined' }; 138 | } 139 | return { isValid: true }; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/ui/scene-tree/scene-tree-node.ts.css: -------------------------------------------------------------------------------- 1 | pix3-scene-tree-node { 2 | display: block; 3 | } 4 | 5 | .tree-node { 6 | margin: 0; 7 | } 8 | 9 | .tree-node__content { 10 | display: grid; 11 | grid-template-columns: auto auto 1fr auto; 12 | align-items: center; 13 | gap: 0.35rem; 14 | padding: 0.08rem 0.18rem; 15 | border-radius: 0.15rem; 16 | background: transparent; 17 | border: none; 18 | transition: background 120ms ease; 19 | } 20 | 21 | .tree-node__content:hover { 22 | background: rgba(56, 62, 74, 0.18); 23 | } 24 | 25 | .tree-node__content:focus-visible { 26 | outline: 2px solid rgba(255, 207, 51, 0.6); 27 | outline-offset: 1px; 28 | } 29 | 30 | .tree-node__content--selected { 31 | background: rgba(48, 113, 255, 0.13); 32 | } 33 | 34 | .tree-node__content--primary { 35 | box-shadow: none; 36 | } 37 | 38 | .tree-node__expander { 39 | display: inline-flex; 40 | align-items: center; 41 | justify-content: center; 42 | width: 1rem; 43 | min-width: 1rem; 44 | height: 1rem; 45 | padding: 0; 46 | border: none; 47 | background: transparent; 48 | color: rgba(245, 247, 250, 0.45); 49 | font-size: 0.68rem; 50 | line-height: 1; 51 | font: inherit; 52 | } 53 | 54 | .tree-node__expander--button { 55 | cursor: pointer; 56 | } 57 | 58 | .tree-node__expander--button:hover { 59 | color: rgba(245, 247, 250, 0.7); 60 | } 61 | 62 | .tree-node__expander--button:focus-visible { 63 | outline: 2px solid rgba(255, 207, 51, 0.8); 64 | border-radius: 0.35rem; 65 | } 66 | 67 | .tree-node__expander::before { 68 | content: ''; 69 | } 70 | 71 | .tree-node__expander--visible::before { 72 | content: '▾'; 73 | } 74 | 75 | .tree-node__expander--collapsed::before { 76 | content: '▸'; 77 | } 78 | 79 | .tree-node__icon { 80 | display: inline-flex; 81 | align-items: center; 82 | justify-content: center; 83 | width: 1rem; 84 | height: 1rem; 85 | color: rgba(245, 247, 250, 0.65); 86 | flex-shrink: 0; 87 | } 88 | 89 | .tree-node__icon svg { 90 | display: block; 91 | width: 100%; 92 | height: 100%; 93 | } 94 | 95 | .tree-node__label { 96 | display: inline-flex; 97 | flex-direction: column; 98 | gap: 0.18rem; 99 | align-items: flex-start; 100 | } 101 | 102 | .tree-node__header { 103 | display: inline-flex; 104 | flex-wrap: wrap; 105 | align-items: center; 106 | gap: 0.45rem; 107 | } 108 | 109 | .tree-node__name { 110 | font-weight: 500; 111 | letter-spacing: 0.01em; 112 | } 113 | 114 | .tree-node__type { 115 | display: inline-block; 116 | padding: 0.1rem 0.35rem; 117 | border-radius: 999px; 118 | font-size: 0.64rem; 119 | letter-spacing: 0.08em; 120 | text-transform: uppercase; 121 | background: rgba(74, 88, 112, 0.55); 122 | color: rgba(236, 240, 248, 0.82); 123 | align-self: flex-start; 124 | } 125 | 126 | .tree-node__instance { 127 | font-size: 0.7rem; 128 | color: rgba(236, 239, 243, 0.6); 129 | } 130 | 131 | .tree-children { 132 | list-style: none; 133 | margin-top: 0.08rem; 134 | margin-bottom: 0.12rem; 135 | margin-left: 0; 136 | margin-right: 0; 137 | padding-inline-start: 1rem; 138 | border-inline-start: none; 139 | } 140 | 141 | .tree-children > li { 142 | margin: 0; 143 | padding: 0; 144 | } 145 | 146 | .tree-children pix3-scene-tree-node { 147 | display: block; 148 | } 149 | 150 | /* Drag and drop states */ 151 | .tree-node__content--dragging { 152 | opacity: 0.5; 153 | background: rgba(200, 200, 200, 0.1); 154 | } 155 | 156 | .tree-node__content--drag-over-top { 157 | border-top: 2px solid rgba(48, 113, 255, 0.8); 158 | padding-top: 0.06rem; 159 | } 160 | 161 | .tree-node__content--drag-over-inside { 162 | background: rgba(48, 113, 255, 0.2); 163 | border-radius: 0.2rem; 164 | box-shadow: inset 0 0 0 2px rgba(255, 207, 51, 0.4); 165 | } 166 | 167 | .tree-node__content--drag-over-bottom { 168 | border-bottom: 2px solid rgba(48, 113, 255, 0.8); 169 | padding-bottom: 0.06rem; 170 | } 171 | 172 | .tree-node__content--drop-disabled { 173 | opacity: 0.4; 174 | pointer-events: none; 175 | } 176 | 177 | .tree-node__buttons { 178 | display: inline-flex; 179 | align-items: center; 180 | gap: 0.18rem; 181 | margin-left: auto; 182 | } 183 | 184 | .tree-node__button { 185 | display: inline-flex; 186 | align-items: center; 187 | justify-content: center; 188 | width: 0.8rem; 189 | height: 0.8rem; 190 | min-width: 0.8rem; 191 | min-height: 0.8rem; 192 | padding: 0; 193 | border: none; 194 | background: transparent; 195 | color: rgba(245, 247, 250, 0.45); 196 | border-radius: 0.2rem; 197 | cursor: pointer; 198 | transition: all 120ms ease; 199 | flex-shrink: 0; 200 | } 201 | 202 | .tree-node__button:hover { 203 | color: rgba(245, 247, 250, 0.7); 204 | background: rgba(245, 247, 250, 0.08); 205 | } 206 | 207 | .tree-node__button:focus-visible { 208 | outline: 2px solid rgba(255, 207, 51, 0.8); 209 | outline-offset: 1px; 210 | } 211 | 212 | .tree-node__button--active { 213 | color: rgba(255, 207, 51, 0.9); 214 | background: rgba(255, 207, 51, 0.12); 215 | } 216 | 217 | .tree-node__button--active:hover { 218 | color: rgba(255, 207, 51, 1); 219 | background: rgba(255, 207, 51, 0.18); 220 | } 221 | 222 | .tree-node__button svg { 223 | display: block; 224 | width: 100%; 225 | height: 100%; 226 | } 227 | 228 | --------------------------------------------------------------------------------