Compare commits

..

96 Commits

Author SHA1 Message Date
add1ece149 Added new relic license key to env.production
All checks were successful
Build and Deploy / build (push) Successful in 1m41s
2026-01-13 17:29:51 -06:00
421cd97fe9 Add console log forwarding to New Relic and enable application logging
All checks were successful
Build and Deploy / build (push) Successful in 1m47s
- Add console shim to forward log/error/warn/info to New Relic
- Enable application_logging with forwarding in newrelic.cjs
- Import newrelic in server.js for recordLogEvent API
- Update CI workflow with New Relic config

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:21:29 -06:00
58959fe347 Fix newrelic config to use .cjs extension for CommonJS
Rename newrelic.js to newrelic.cjs to work with ES module projects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:18:20 -06:00
d9cd0692b5 Add New Relic Node.js APM monitoring to backend
- Install newrelic package for server-side APM
- Create newrelic.js configuration with distributed tracing enabled
- Update npm scripts to preload agent via -r flag for ES modules
- Correlates with existing browser agent for end-to-end tracing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:15:27 -06:00
3155cc930f Fix camera positioning, label z-fighting, and remove dead code
- Fix desktop camera to be directly above platform by resetting local position
- Increase label back offset from 0.001 to 0.005 to prevent z-fighting
- Use refreshBoundingInfo({}) for consistency with codebase
- Remove unused copyToPublic from pouchData.ts
- Remove dead DiagramEntityAdapter and integration/gizmo module
- Remove unused netlify functions and integration utilities
- Clean up unused imports and commented code across multiple files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:06:18 -06:00
8c2b7f9c7d Fix diagram text sync, resize handle positioning, and PouchDB delete handling
- Fix diagramObject text setter to update entity before notifying observers
- Improve ResizeGizmo handle positioning directly at corners/faces with constant screen-size scaling
- Fix PouchDB sync to handle deleted documents using _id field for compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:16:43 -06:00
960c64984e Add New Relic browser agent for monitoring
Some checks failed
Build and Deploy / build (push) Failing after 6m34s
- Add @newrelic/browser-agent dependency
- Initialize browser agent in webApp.ts with distributed tracing enabled

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 07:15:18 -06:00
d79f4efa98 Add Auth0 environment variables to build step
Some checks failed
Build and Deploy / build (push) Failing after 6m31s
VITE_AUTH0_CLIENTID and VITE_AUTH0_DOMAIN are needed at build time
as they get embedded into the frontend bundle by Vite.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 06:42:40 -06:00
8bfe7bb174 Add Cloudflare environment variables to CI/CD pipeline
All checks were successful
Build and Deploy / build (push) Successful in 1m34s
- Update build.yml to create .env.production from Gitea secrets
  - ANTHROPIC_API_KEY, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN
  - Secure file with chmod 600 (owner read only)
  - Preserve env file across deployments

- Update start.sh to source .env.production if it exists
  - Parse and export variables before starting server
  - Skip comments and empty lines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 06:40:33 -06:00
03217f3e65 Add Cloudflare Workers AI provider and multiple AI chat improvements
- Add Cloudflare Workers AI as third provider alongside Claude and Ollama
  - New cloudflare.js API handler with format conversion
  - Tool converter functions for Cloudflare's OpenAI-compatible format
  - Handle [TOOL_CALLS] and [Called tool:] text formats from Mistral
  - Robust parser that handles truncated JSON responses

- Add usage tracking with cost display
  - New usageTracker.js service for tracking token usage per session
  - UsageDetailModal component showing per-request breakdown
  - Cost display in ChatPanel header

- Add new diagram manipulation features
  - Entity scale and rotation support via modify_entity tool
  - Wikipedia search tool for researching topics before diagramming
  - Clear conversation tool to reset chat history
  - JSON import from hamburger menu (moved from ChatPanel)

- Fix connection label rotation in billboard mode
  - Labels no longer have conflicting local rotation when billboard enabled
  - Update rotation when rendering mode changes

- Improve tool calling reliability
  - Add MAX_TOOL_ITERATIONS safety limit
  - Break loop after model switch to prevent context issues
  - Increase max_tokens to 4096 to prevent truncation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 06:31:43 -06:00
fd81ba3be7 update group permissions in deploy.
All checks were successful
Build and Deploy / build (push) Successful in 1m38s
2025-12-30 10:43:26 -06:00
c58ce483dd update group permissions in deploy.
Some checks failed
Build and Deploy / build (push) Failing after 1m35s
2025-12-30 10:28:15 -06:00
13ecd5a626 Add quick console test.
Some checks failed
Build and Deploy / build (push) Failing after 1m33s
2025-12-30 10:22:01 -06:00
82807dcfce Fix deployment to preserve data dir in place
All checks were successful
Build and Deploy / build (push) Successful in 1m35s
Use find to delete all files except data directory instead of
moving to /tmp which fails due to sticky bit permissions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:48:35 -06:00
739775ea94 Fix deployment to work within /opt/immersive directory
Some checks failed
Build and Deploy / build (push) Failing after 1m29s
Move contents instead of directory itself to avoid permission issues
with gitea-runner user who has write access inside but not to /opt.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:40:32 -06:00
e2216c17e8 Add Alpine Linux service setup and CI/CD deployment
Some checks failed
Build and Deploy / build (push) Failing after 1m44s
Node.js CI / build (push) Has been cancelled
- Add ALPINE_SERVICE.md with full setup instructions
- Add start.sh script for OpenRC service
- Update build.yml for deployment to /opt/immersive
- Configure proper permissions for immersive user
- Add Gitea runner setup instructions with sudo config
- Add .env.production to gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:21:08 -06:00
33019c116b change server start.
Some checks are pending
Node.js CI / build (push) Waiting to run
Build / build (push) Successful in 2m19s
2025-12-30 07:11:36 -06:00
bd833e236a quick test of gitea runner config.
Some checks are pending
Build / build (push) Waiting to run
Node.js CI / build (push) Waiting to run
2025-12-30 07:00:35 -06:00
0916829ba2 quick test of gitea runner config.
Some checks failed
Build / build (push) Has been cancelled
Node.js CI / build (push) Has been cancelled
2025-12-30 06:58:43 -06:00
122c0d2ab0 quick test of gitea runner config.
Some checks are pending
Build / build (push) Waiting to run
Node.js CI / build (push) Waiting to run
2025-12-29 20:33:04 -06:00
1e174e81d3 Add local database mode for browser-only diagrams
Some checks failed
Node.js CI / build (push) Waiting to run
Build / build (push) Failing after 15m8s
- Add /db/local/:db path type that stores diagrams locally without syncing
- New diagrams now default to local storage (browser-only)
- Share button creates public copy when sharing local diagrams
- Add storage type badges (Local/Public/Private) in diagram manager
- Add GitHub Actions workflow for automated builds
- Block local- database requests at server with 404

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:13:43 -06:00
a772372b2b Add public URL sharing with express-pouchdb sync
- Add express-pouchdb for self-hosted PouchDB sync server
- Public databases (/db/public/:db) accessible without auth
- Add auth middleware for public/private database access
- Simplify share button to copy current URL to clipboard
- Move feature config from static JSON to dynamic API endpoint
- Add PouchDB sync to PouchData class for real-time collaboration
- Fix Express 5 compatibility by patching req.query
- Skip express.json() for /pouchdb routes (stream handling)
- Remove unused PouchdbPersistenceManager and old share system

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 11:49:56 -06:00
74a2d179b9 Add Ollama as alternative AI provider and modify_connection tool
Ollama Integration:
- Add providerConfig.js for managing AI provider settings
- Add toolConverter.js to convert between Claude and Ollama formats
- Add ollama.js API handler with function calling support
- Update diagramAI.ts with Ollama models (llama3.1, mistral, qwen2.5)
- Route requests to appropriate provider based on selected model
- Use 127.0.0.1 to avoid IPv6 resolution issues

New modify_connection Tool:
- Add modify_connection tool to change connection labels and colors
- Support finding connections by label or by from/to entities
- Add chatModifyConnection event handler in diagramManager
- Clarify in tool descriptions that empty string removes labels

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:08:17 -06:00
5891dfd6b7 Fix hamburger menu visibility and feature config format
- Fix z-index layering so hamburger menu appears above canvas
  - Lower canvas zIndex from 1000 to 1
  - Add zIndex={100} to Affix and Menu components
  - Add position="bottom-start" to prevent dropdown going off-screen

- Update feature configs to use string states instead of booleans
  - Convert all JSON configs from true/false to "on"/"off"/"coming-soon"/"pro"
  - Fix BASIC_FEATURE_CONFIG to enable core features for logged-in users
  - This fixes menu items not responding to clicks when authenticated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:00:37 -06:00
a7aa385d98 Add model selection and ground-projected directional placement
Model management:
- Add list_models, get_current_model, set_model tools
- Support Claude Sonnet 4, Opus 4, and Haiku 3.5
- Model selection persists for session duration

Directional placement improvements:
- Compute ground-projected forward/right vectors from camera
- Accounts for camera being parented to moving platform
- "Forward" means forward on ground plane, ignoring vertical look angle
- Pre-calculate example positions for left/right/forward/back
- Update system prompt to use get_camera_position for relative directions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 16:14:44 -06:00
c9dc61b918 Add camera position tool and fix entity modification bugs
- Add get_camera_position tool for positioning entities relative to user view
- Fix color change causing entities to disappear (dispose mesh before rebuild)
- Fix connections being lost when modifying entities (defer disposal, let
  scene observer re-find meshes after they're recreated with same ID)
- Add position and color setters to DiagramObject for real-time updates
- Add debug logging to diagramAI and claude.js for troubleshooting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 15:59:30 -06:00
2fd87b2d14 Fix entity connections and add clear diagram tool
Connection fixes:
- Add chatResolveEntity event to resolve labels to entity IDs
- Update connectEntities to resolve from/to labels before creating connection
- Auto-generate connection labels as "{from label} to {to label}"

Clear diagram tool:
- Add clear_diagram tool with confirmation requirement
- Claude prompts user for confirmation before executing
- Clears all entities from diagram and resets session
- Syncs empty entity list to server after clearing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 14:02:11 -06:00
7769910027 Add server-side session persistence for chat
Implement session management to maintain conversation history and entity
context across page refreshes. Sessions are stored in-memory and include:
- Conversation history (stored server-side, restored on reconnect)
- Entity snapshots synced before each message for LLM context
- Auto-injection of diagram state into Claude's system prompt

Key changes:
- Add session store service with create/resume/sync/clear operations
- Add session API endpoints (/api/session/*)
- Update Claude API to inject entity context and manage history
- Update ChatPanel to initialize sessions and sync entities
- Add debug endpoint for inspecting session state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 13:42:01 -06:00
1152ab0d0c Add Express API server for Claude API proxy
- Add Express server with vite-express for combined frontend/API serving
- Create modular API route structure (server/api/)
- Implement Claude API proxy with proper header injection
- Support split deployment via API_ONLY and ALLOWED_ORIGINS env vars
- Remove Claude proxy from Vite config (now handled by Express)
- Add migration plan documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:32:49 -06:00
1ccdab2780 Added chat interface. 2025-12-20 11:25:18 -06:00
e714c3d3df Added chat interface. 2025-12-20 11:25:14 -06:00
54e5017c38 Added chat interface. 2025-12-20 11:24:31 -06:00
8a78e45440 version bump 2025-12-19 15:32:28 -06:00
1c50dd5c84 Implement three-state feature flag system with upgrade badges
Feature States:
- 'on': Feature fully accessible
- 'off': Feature hidden from menus
- 'coming-soon': Visible with "Coming Soon!" badge, not clickable
- 'basic': Visible with "Sign Up for Free" badge, triggers Auth0 login
- 'pro': Visible with "Upgrade to Pro" badge (for future upgrade flow)

Changes:
- Update FeatureState type to support 5 states (on/off/coming-soon/basic/pro)
- Consolidate GUEST_FEATURE_CONFIG as DEFAULT_FEATURE_CONFIG
- Create ComingSoonBadge component for coming-soon features
- Create UpgradeBadge component for basic/pro tier requirements
- Update VR Experience hamburger menu to maintain open/closed state
- Make menu default to open, persist state in localStorage
- Make 'basic' features clickable to trigger Auth0 sign-in
- Update createDiagramModal to show appropriate badges
- Fix camera initial position to match VR rig (prevent flip on load)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 14:33:11 -06:00
31dd8a89da Clean up code formatting and remove unused functions
- Remove unused createGrassGround function from customEnvironment
- Remove commented HemisphericLight code
- Fix deviceDetection to use proper VR detection instead of hardcoded true
- Clean up whitespace in spinner.ts and animatedLineTexture.ts
- Pass empty object to refreshBoundingInfo to match API signature

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 13:23:17 -06:00
1098f03c7d Optimize STL asset loading with promise-based caching
- Replace ImportMeshAsync with LoadAssetContainerAsync for person.stl
- Cache loading promise to prevent race conditions and multiple fetches
- Use instantiateModelsToScene() to create mesh instances from cached container
- Simplify buildMesh signature to use DefaultScene singleton
- Add Havok physics WASM prefetch hint to index.html

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 13:19:24 -06:00
0e053bf69c Add Quest VR onboarding flow with auto-entry prompt
Implemented comprehensive VR onboarding experience for Quest users:

- Add demo database template with pre-built architecture diagram
- Implement automatic demo template loading on first visit
- Create VREntryPrompt component for seamless VR mode entry
- Add device detection utilities for Quest/VR headset identification
- Integrate export/import functionality for diagram templates
- Add About page with device-aware CTA and VR benefits
- Remove legacy tutorial and FirstVisitVr modal for demo flow
- Add upgrade prompts and tiered feature configuration

Quest users now see prominent VR entry prompt when navigating to /db/** paths,
providing one-tap entry into immersive mode after scene initialization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 10:47:28 -06:00
0b81605bdf Fix label positioning to use world space bounding boxes
Labels were incorrectly mixing local and global transform matrices, causing
incorrect positioning on scaled/rotated meshes. Now properly converts world
space bounding box positions to mesh local space using temporary TransformNode.

Changes:
- updateTextNode.ts: Use boundingBox.maximumWorld instead of boundingSphere.maximum
- diagramObject.ts: Add empty object param to refreshBoundingInfo()
- inputTextView.ts: Adjust input handle default position and mesh offsets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 16:52:04 -06:00
7849bf4eb2 Extract toolbox buttons into standalone reusable classes
Refactor Toolbox button code into separate, focused classes following Single Responsibility Principle.

New Button Classes:
- ExitXRButton (src/objects/buttons/ExitXRButton.ts)
  * Encapsulates exit XR functionality
  * Takes XR experience, scene, parent, and optional position
  * Handles click events to exit XR session
  * Provides dispose() and transform getter

- ConfigButton (src/objects/buttons/ConfigButton.ts)
  * Encapsulates config panel toggle functionality
  * Uses dependency injection for toggle callback
  * Configurable position relative to parent
  * Provides dispose() and transform getter

- RenderModeButton (src/objects/buttons/RenderModeButton.ts)
  * Encapsulates render mode cycling functionality
  * Internally manages render mode state
  * Automatically recreates button with new label on mode change
  * Cycles through all available rendering modes
  * Provides dispose() and transform getter

Toolbox Changes:
- Removed createRenderModeButton() and updateRenderModeButton() methods
- Simplified setupXRButton() to instantiate button classes
- Reduced button-related code by ~120 lines
- Added button instance properties for cleanup
- Clean, declarative button creation with clear positioning

Benefits:
- Single Responsibility Principle - each button class has one purpose
- Reusability - buttons can be used anywhere in the app
- Testability - each button can be tested independently
- Cleaner code - Toolbox class focuses on core tool management
- Better dependencies - clear interfaces and dependency injection
- Easier maintenance - button logic isolated and self-contained

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 16:14:01 -06:00
e329b95f2f Fix AppConfig persistence and consolidate handle storage
AppConfig Persistence Fixes:
- Fix constructor to properly handle null localStorage values
- Add null check before JSON.parse to prevent errors
- Create fresh config copies with spread operator to avoid reference issues
- Add better error handling and logging for config loading
- Initialize handles array properly

React ConfigModal Improvements:
- Fix config initialization to get fresh values on render instead of stale module-level values
- Separate useEffect hooks for each config property (prevents unnecessary updates)
- Fix SegmentedControl string-to-number conversion (locationSnaps now use "0.01", "0.1" format)
- Enable/disable logic now properly sets values to 0 when disabled

Handle Storage Consolidation:
- Create dynamic HandleConfig type with Vec3 for serializable position/rotation/scale
- Add handles array to AppConfigType for flexible handle storage
- Replace individual localStorage keys with centralized AppConfig storage
- Add handle management methods: getHandleConfig, setHandleConfig, removeHandleConfig, getAllHandleConfigs
- Update Handle class to read from AppConfig instead of direct localStorage
- Update dropMesh to save handles via AppConfig using Vec3 serialization
- Convert between BabylonJS Vector3 and serializable Vec3 at conversion points

Benefits:
- Single source of truth for all configuration
- Proper localStorage persistence across page reloads
- Dynamic handle creation without code changes
- Type-safe configuration with proper JSON serialization
- Consolidated storage (one appConfig key instead of multiple handle-* keys)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 15:34:40 -06:00
25963d5289 version bump 2025-11-19 13:35:40 -06:00
aa0810be02 Refactor Handle class and fix VR positioning
Major improvements to Handle class architecture:
- Replace positional constructor parameters with options object pattern (HandleOptions interface)
- Add automatic platform parenting - handles now find and parent themselves to platform
- Rename idStored → hasStoredPosition for better clarity
- Remove unused staort() method
- Improve position/rotation persistence with better error handling
- Add comprehensive JSDoc documentation
- Use .parent instead of setParent() for proper local space coordinates

Update all Handle usage sites:
- Toolbox: Use new API with position (-.5, 1.5, .5) and zero rotation
- InputTextView: Use new API with position (0, 1.5, .5) and zero rotation
- VRConfigPanel: Use new API with position (.5, 1.5, .5) and zero rotation
- Remove manual platform parenting logic (61 lines of duplicated code removed)
- Remove local position offsets that were overriding handle positions

Fix VR entry positioning:
- Disable camera-relative positioning in groundMeshObserver
- Handles now use their configured defaults or saved localStorage positions
- Positions are now in platform local space as intended

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:28:48 -06:00
15c6617151 Fix VR config panel positioning to prevent overflow below handle
The VR config panel was extending 55cm below the handle bar due to
incorrect positioning calculation.

Problem:
- Panel dimensions: 2m wide × 1.5m tall
- Panel was positioned at y=0.2m above handle center
- This placed panel bottom at y=-0.55m (55cm BELOW handle)
- Panel overflowed significantly below the handle bar

Solution:
- Calculate proper position based on panel height
- Position panel center at y=0.8m (0.75m half-height + 0.05m gap)
- Panel bottom now sits 5cm above handle, matching toolbox appearance
- Add 0.6x scaling to match toolbox compact size (1.2m×0.9m actual)

Result:
- Panel bottom aligns just above handle bar
- Consistent visual relationship with toolbox
- Comfortable viewing distance and ergonomics in VR

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 09:46:51 -06:00
adc80c54c4 Optimize animated connection textures and fix material texture bleeding
Performance Optimizations (~90% improvement):
- Implement texture color caching in AnimatedLineTexture
  - Reuse textures for connections with same color
  - Reduces texture count by 70-90% with duplicate colors
- Reduce animation update frequency from every frame to every other frame
  - Halves CPU-to-GPU texture updates while maintaining smooth animation
- Add texture preloading for all 16 toolbox colors
  - Eliminates first-connection creation stutter
- Add GetCacheStats, ClearCache, and PreloadTextures utility methods

Bug Fixes:
1. Fix texture disappearing when moving objects with connections
   - Root cause: Shared cached textures were disposed on connection update
   - Solution: Never dispose cached textures, only swap references
   - Add safety check in DisposeTexture to prevent cached texture disposal

2. Fix UI text textures bleeding to normal materials
   - Add metadata.isUI = true to 10+ UI components:
     - Button.ts (with unique material names per button)
     - handle.ts, roundButton.ts, createLabel.ts, updateTextNode.ts
     - spinner.ts, vrConfigPanel.ts, buildImage, introduction.ts
     - ResizeGizmo.ts
   - Update LightmapGenerator filter to check metadata.isUI first
   - Change exact name match to startsWith for button materials

3. Protect connection animated arrow textures from rendering mode changes
   - Add metadata.isConnection = true to connection materials
   - Update LightmapGenerator to skip connection materials
   - Connections maintain animated arrows in all rendering modes

Technical Details:
- Texture caching follows existing LightmapGenerator pattern
- All UI materials now consistently marked with metadata flags
- Rendering mode filter uses metadata-first approach with fallback checks
- Connection materials preserve textures via metadata.preserveTextures flag

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 16:37:22 -06:00
0e318e7cc7 Fix Fly Mode and Snap Turn not applying at runtime in VR
Add Observable subscription to Rigplatform so Fly Mode and Snap Turn
settings take effect immediately when changed in the VR config panel,
instead of only applying after exiting and re-entering XR.

Changes to src/controllers/rigplatform.ts:
- Import Observer, appConfigInstance, and AppConfigType
- Add _configObserver property to track subscription
- Add _subscribeToConfigChanges() method in constructor
- Subscribe to onConfigChangedObservable to update flyMode and turnSnap
- Add dispose() method to clean up observer and controllers
- Log config changes for debugging

Changes to src/menus/vrConfigPanel.ts:
- Remove unused index parameter in forEach loop

Root cause: Settings were only applied once at Rigplatform initialization
in groundMeshObserver.ts. Config changes during VR session were saving to
localStorage but not updating the running Rigplatform instance.

Result: Fly Mode and Snap Turn now update in real-time when changed in VR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 15:27:08 -06:00
5091ca0bab Implement Phase 7: Add Label Rendering Mode controls to VR config panel
Complete the final phase of VR configuration panel implementation by adding
Label Rendering Mode selection controls. This allows users to configure how
diagram labels are rendered in the VR environment.

Changes:
- Add radio-style buttons for 4 label rendering modes:
  - Fixed: Static orientation
  - Billboard: Always faces camera (default)
  - Dynamic: Coming soon (disabled)
  - Distance-based: Coming soon (disabled)
- Implement disabled styling for future modes (gray background, 50% opacity)
- Wire up to appConfigInstance.setLabelRenderingMode()
- Update UI when config changes from external sources (2D modal)
- Add updateLabelModeButtonStates() for visual state management

This completes all 7 phases of VRCONFIGPLAN.md implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 15:05:13 -06:00
3002181160 Implement Phase 6: Snap Turn controls for VR config panel
Add fully functional Snap Turn controls:
- Toggle button (Enabled/Disabled) with blue/gray color coding
- 5 angle buttons: 22.5°, 45°, 90°, 180°, 360°
- Selected button highlighted in blue with bold text
- Disabled appearance when snap is off (50% opacity)
- Wire up to appConfigInstance.setTurnSnap()
- Update UI from config observable changes

Follows same pattern as Rotation Snap section but for snap turning.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 13:44:53 -06:00
f8ae71a962 Implement Phase 5: Fly Mode toggle for VR config panel
Add Fly Mode toggle control:
- Single toggle button showing "Fly Mode Enabled" or "Fly Mode Disabled"
- 400px wide button with blue/gray color coding
- Wire up to appConfigInstance.setFlyMode()
- Update UI from config observable changes

Simplest section - just one boolean toggle, no value selection needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 13:36:19 -06:00
c66da87401 Migrate from legacy config to new AppConfig singleton system
Remove dual config system and migrate all code to use appConfigInstance.

**Phase 1: Update VR Controller Code**
- snapAll.ts: Replace getAppConfig() with appConfigInstance.current
  - Use `rotateSnap > 0` instead of rotationSnapEnabled flag
  - Use `locationSnap > 0` instead of locationSnapEnabled flag
  - Remove parseFloat() calls (values already numbers)
- groundMeshObserver.ts: Direct property replacement
  - flyModeEnabled → flyMode
  - snapTurnSnap → turnSnap (remove parseFloat)
- customPhysics.ts: Add enabled checks and update
  - Add `> 0` checks (was applying unconditionally)
  - Use locationSnap and rotateSnap directly

**Phase 2: Remove Legacy Config Bridge**
- vrConfigPanel.ts: Remove syncLegacyConfig() method and all calls
- configModal.tsx: Remove legacy localStorage 'config' writes

**Phase 3: Cleanup**
- appConfig.ts: Remove legacy code (ConfigType, getAppConfig(), setAppConfig())
- Remove unused log import

**Benefits:**
- Eliminates dual config system confusion
- Fixes precision error from string "0" values
- Single source of truth via appConfigInstance
- Reactive updates via Observable pattern
- Cleaner, simpler codebase

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 13:20:18 -06:00
3cf3d996dc Implement Phase 4 and fix config sync for actual snap functionality
Phase 4: Add Rotation Snap controls
- Toggle button (Enabled/Disabled) with blue/gray color coding
- 5 rotation value buttons: 22.5°, 45°, 90°, 180°, 360°
- Selected button highlighted in blue with bold text
- Disabled appearance when snap is off (50% opacity)
- Wire up to appConfigInstance.setRotateSnap()
- Update UI from config observable changes

Critical fix: Sync to legacy config system
- Add syncLegacyConfig() method to write to localStorage 'config' key
- Call after all snap value changes (location and rotation)
- Legacy config is used by snapAll.ts for actual object snapping
- Ensures VR config changes affect real VR object manipulation
- Matches ConfigModal pattern for backward compatibility

Without this sync, changes in VR panel had no effect on actual snapping behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 12:53:41 -06:00
5889a1ed79 Implement Phase 3: Location Snap controls for VR config panel
Add fully functional Location Snap controls:
- Toggle button (Enabled/Disabled) with blue/gray color coding
- 5 snap value buttons: 1cm, 5cm, 10cm, 50cm, 1m
- Selected button highlighted in blue with bold text
- Disabled appearance when snap is off (50% opacity)
- Wire up to appConfigInstance.setGridSnap()
- Update UI from config observable changes

Fix layout issues:
- Change texture aspect ratio from 2048x2048 to 2048x1536 (4:3) to match plane dimensions
- Add adaptHeightToChildren to section containers for proper auto-sizing
- Add horizontal alignment to button containers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 12:13:22 -06:00
be311e6dc8 Implement Phase 2 UI layout and toolbox integration for VR config panel
Phase 2: Add UI layout structure to VRConfigPanel
- Create 5 section containers with titles (Location Snap, Rotation Snap, Fly Mode, Snap Turn, Label Rendering Mode)
- Add visual separators between sections using Rectangle components
- Style with proper padding and spacing for VR readability (60px titles, blue #4A9EFF)
- Store section content containers as private properties for Phase 3-7 controls

Toolbox Integration (Phase 8 partial):
- Instantiate VRConfigPanel in DiagramMenuManager constructor
- Add "Config" button to toolbox (bottom-left, opposite Exit VR button)
- Wire up click handler to toggle panel visibility
- Add B-button positioning logic to reposition panel with other UI elements
- Pass DiagramMenuManager reference to Toolbox.setXR() for panel access

The panel now has complete skeleton structure and can be tested in VR:
- Click "Config" button on toolbox to show/hide panel
- Grab handle to reposition and test ergonomics
- Press B-button to auto-lower panel if too high
- 2m x 1.5m panel size optimized for VR viewing distance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 12:03:24 -06:00
aa41895675 Add VR configuration panel implementation plan and Phase 1 foundation
Create VRCONFIGPLAN.md with comprehensive 10-phase implementation guide for building an immersive WebXR configuration panel using AdvancedDynamicTexture.

Implement Phase 1: Core panel setup
- Create VRConfigPanel class following Handle pattern for grabbability
- Set up 2m x 1.5m plane mesh with high-resolution ADT (2048x2048)
- Initialize main StackPanel container with title
- Add show/hide/dispose methods for panel lifecycle
- Integrate with appConfigInstance observable for config changes
- Auto-parent to platform for world movement tracking

The panel starts hidden and provides foundation for adding configuration controls in subsequent phases (location snap, rotation snap, fly mode, snap turn, label rendering mode).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 11:53:47 -06:00
970f6fc78a Fix label positioning, add billboard mode, fix XR entry shift, and fix config system
Label Positioning Fixes:
- Fix labels accounting for mesh scaling using maximumWorld coordinates
- Labels now properly positioned on scaled objects (spheres, boxes, etc.)
- Restore world→local coordinate transformation in updateLabelPosition

Billboard Mode Implementation:
- Add configurable label rendering modes: fixed, billboard, dynamic, distance-based
- Implement billboard mode (labels always face camera using BILLBOARDMODE_Y)
- Add label rendering mode to AppConfig with default 'billboard'
- Add UI selector in ConfigModal for label rendering mode
- Observable pattern updates all existing labels when mode changes

XR Entry Positioning Fix:
- Synchronize desktop camera position to platform before entering XR
- Transfer camera world position and rotation to prevent scene shift
- Reset physics velocity on XR entry to prevent drift
- Add debug logging for position synchronization

Config System Architecture Fix:
- Create singleton appConfigInstance to ensure single source of truth
- Update DiagramObject to use singleton instead of creating instances
- Update DiagramManager to use singleton
- Fix ConfigModal to update AppConfig directly (was only updating legacy config)
- ConfigModal now triggers Observable notifications via appConfigInstance setters
- Maintain legacy config for backward compatibility
- Fixes issue where label rendering mode changes didn't take effect

Files Modified:
- src/diagram/diagramObject.ts - Label positioning, billboard mode, singleton config
- src/diagram/diagramManager.ts - Use singleton config
- src/util/appConfig.ts - Add labelRenderingMode, export singleton
- src/util/appConfigType.ts - Add LabelRenderingMode type
- src/react/pages/configModal.tsx - Update AppConfig directly, add label mode UI
- src/util/functions/groundMeshObserver.ts - Add camera position sync on XR entry
- public/api/user/features - Update test config
- package.json - Version bump

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 08:52:04 -06:00
c1503d959e Add configurable feature management system with JSON-based feature flags
Implement comprehensive feature toggle system allowing menu options and features
to be controlled via JSON configuration fetched from API endpoint or static files.

Core System:
- Create FeatureConfig type system with page, feature, and limit-based flags
- Add React Context (FeatureProvider) that fetches from /api/user/features
- Implement custom hooks (useFeatures, useIsFeatureEnabled, useFeatureLimit, etc.)
- Default config disables everything except home page

Integration:
- Update PageHeader to filter menu items based on page flags
- Add ProtectedRoute component to guard routes
- Update VR menu to conditionally render items based on feature flags
- Update CreateDiagramModal to enable/disable options (private, encrypted, invite)
- Update ManageDiagramsModal to use configurable maxDiagrams limit

Configuration Files:
- Add static JSON files for local testing (none, free, basic, pro tiers)
- Add dev proxy for /api/user/features endpoint
- Include README with testing instructions

Updates:
- Complete CLAUDE.md naming conventions section
- Version bump to 0.0.8-27

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 06:52:39 -06:00
6ea6eaaac7 Implement SVG-based dynamic connection arrows with toolbox color matching
Replace static arrow.png with dynamically generated SVG arrows that match
the source object's color from the toolbox palette.

Changes:
- Replace arrow.png loading with inline SVG generation (32x32 right-pointing triangle)
- Add CreateColoredTexture() method to generate arrows in any hex color
- Extract color from source mesh using three-priority fallback system:
  1. mesh.metadata.color (most reliable)
  2. sourceMesh.id parsing (e.g., "tool-#box-template-#FF0000")
  3. material color extraction (backwards compatibility)
- Match extracted color to closest of 16 toolbox colors using Euclidean distance
- Track all textures in Set for synchronized animation
- Add proper texture disposal to prevent memory leaks

Benefits:
- No external arrow.png dependency
- Connections visually match their source object's toolbox color
- Consistent 16-color palette across all connections
- Efficient texture sharing for matching colors
- SVG scales perfectly at any resolution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 06:24:42 -06:00
2915717a3a Adjust ResizeGizmo handle appearance and sizing
Updated handle visual properties:
- Changed handle color from yellow to blue for better visibility
- Increased base handle size from 20% to 50% of corner distance
- Modified distance-based scaling multiplier from 0.2 to 1.0 for improved depth perception
- Removed unused HANDLE_SIZE constant

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 04:57:22 -06:00
6643379133 Add distance-based scaling to ResizeGizmo handles
- Implement camera distance-based scaling so handles appear consistent visual size
- Add updateHandleScaling() method called every frame
- Use 0.2 multiplier for scale factor (adjustable)
- Handles maintain mesh rotation alignment (not billboarded)
- Keeps existing utility layer configuration for compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 20:10:14 -06:00
1ab3deae92 Add face handles and transform tracking to ResizeGizmo
- Add 6 face handles for single-axis scaling (in addition to 8 corner handles for uniform scaling)
- Implement single-axis scaling for face handles vs uniform scaling for corners
- Add automatic handle position updates when target mesh moves or rotates
- Track mesh transform changes using quaternions for accurate rotation detection
- Update handles in real-time during scaling to match new bounding box
- Add FACE_POSITIONS constant array to enums.ts
- Fix handle sizing to use consistent size calculation for all handles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 17:06:32 -06:00
016b1fe6e2 Rebuild ResizeGizmo from scratch with simplified corner-only approach
Completely rewrote ResizeGizmo to be methodical and debuggable:

- Created CORNER_POSITIONS static array with normalized coordinates
- 8 corner handles only (removed face handles for simplicity)
- Handle sizing based on bbox distance (20% of corner-to-center)
- Handle positioning uses vectorsWorld directly
- XR controller ray picking in utility layer
- Edge rendering for hover (white) and grab (blue) states
- Virtual stick scaling: fixed-length ray from controller
- Uniform scaling based on distance ratio
- Snap to 0.1 increments on release only
- Proper XR input setup for existing and new controllers

Key improvements:
- Uses BabylonJS vectorsWorld instead of manual calculations
- Cleaner separation of concerns (picking, input, scaling)
- All private fields use underscore prefix convention
- Better haptic feedback (hover pulse, grab pulse, release pulse)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 16:40:56 -06:00
ebad30ce4d Implement Virtual Stick scaling with modular ResizeGizmo architecture
Refactored ResizeGizmo into modular structure:
- ResizeGizmo.ts: Main implementation with Virtual Stick scaling
- enums.ts: HandleType and HandleState enums
- types.ts: TypeScript interfaces
- index.ts: Barrel exports

Implemented Virtual Stick scaling approach:
- Fixed-length virtual stick extends from controller forward
- Scaling based on distance ratio in mesh local space
- World-to-local coordinate transforms for proper rotation handling
- Smooth continuous scaling during drag (no rounding)
- Snap to 0.1 increments on grip release
- Face handles: round only scaled axis
- Corner handles: round uniformly on all axes

Fixed scaling oscillation issues:
- Freeze handle position updates during active scaling
- Prevents feedback loop between scaling and handle positioning
- Use absoluteRotationQuaternion for proper handle rotation

Added WebXRDefaultExperience parameter to constructor for proper controller integration with manual ray casting in world space.

Added test shortcuts:
- Ctrl+Shift+T: Create test entities (sphere and box)
- Ctrl+Shift+X: Clear all entities

Wired Close button to dispose active ResizeGizmo.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 11:43:17 -06:00
0712abe729 Remove old ResizeGizmo integration code from AbstractController
Clean up AbstractController by removing references to old ResizeGizmo implementation:
- Remove utility layer mesh filtering logic
- Remove auto-show gizmo on hover
- Remove gizmo handle click filtering
- Remove unused Ray import
- Bump version to 0.0.8-26

These changes complete the migration to the new simplified ResizeGizmo architecture.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 05:55:17 -06:00
2c3fba31d3 Reimplement ResizeGizmo as simplified single-file XR gizmo
Complete rewrite of ResizeGizmo with a much simpler architecture:
- Single file implementation (index.ts) replacing multi-file system
- 14 handles: 6 face handles for single-axis scaling, 8 corner handles for uniform scaling
- XR-only interaction using UtilityLayerRenderer
- Billboard scaling for constant screen-size handles
- Grip-based interaction with hover/active visual states (gray/white/blue)
- Single-axis scaling from opposite face (fixed pivot)
- Uniform scaling from center
- Integrated with ClickMenu Size button
- Observable events (onScaleEnd, onScaleDrag) for future integration

Removed old complex implementation files and simplified documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 05:53:26 -06:00
c815db4594 Improve ResizeGizmo hover state and handle interaction
Phase 1 & 2: Handle positioning and wireframe improvements
- Move handles 5% outward from bounding box (was inward)
- Rename boundingBoxPadding → handleOffset for clarity
- Add wireframePadding (3% breathing room around mesh)

Hover boundary detection (prevent loss in whitespace):
- Add isPointerInsideHandleBoundary() with ray-AABB intersection
- Use local space transformation for accurate OBB handling
- Keep HOVER_MESH state when pointer in handle boundary
- Fix: Trust ResizeGizmo state instead of recreating with fake rays

Prevent main scene mesh grab during handle interaction:
- Add ResizeGizmo state check in pointer observable
- Add defense-in-depth guard in grab() method
- Prevents controller from grabbing diagram mesh when hovering handle
- Two-level protection against race conditions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 13:55:18 -06:00
43100ad650 Fix color persistence using metadata and source mesh ID fallback
Colors were being lost during resize operations because toDiagramEntity
extracted color from material.diffuseColor, which is no longer used after
the emissiveColor rendering optimization (commit c7887d7).

Root Causes:
1. Rendering system changed from diffuseColor to emissiveColor
2. Material properties unreliable when materials are shared
3. Material-based extraction broke when properties changed

Solution - Three-Tier Fallback Chain:

Priority 1: mesh.metadata.color
- Most reliable, explicitly set during mesh creation
- Already populated by buildMeshFromDiagramEntity (line 163)

Priority 2: Extract from mesh.sourceMesh.id (InstancedMesh)
- Tool mesh IDs encode color: "tool-BOX-#FF0000"
- Preserves original tool color regardless of material state
- Works for all instanced diagram meshes

Priority 3: Material properties (backwards compatibility)
- Checks emissiveColor first (current system)
- Falls back to diffuseColor (old system)
- Handles both StandardMaterial and PBRMaterial
- Maintains compatibility with non-instanced meshes

Changes:
- Import InstancedMesh from @babylonjs/core
- Replace direct material extraction with fallback chain
- Parse tool mesh ID to extract hex color code
- Normalize colors to lowercase
- Add null checks for safe color extraction

Benefits:
 Independent of material system changes
 Works with shared materials
 Preserves original tool colors
 Backwards compatible
 More reliable than material-only extraction

Files modified:
- toDiagramEntity.ts: Implement fallback chain for color extraction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 08:05:49 -06:00
af52d5992c Fix handle rotation to properly match mesh orientation
Handle meshes now correctly rotate to match the target mesh's world-space
orientation instead of appearing axis-aligned.

Root Cause:
- Handle positions from HandleGeometry are calculated in world space
- Setting mesh.position treats values as local space
- This created coordinate system mismatch when rotation was also set
- Result: rotation appeared to have no effect

Solution:
- Extract rotation from mesh world matrix using quaternion decomposition
- Set rotation FIRST (before position)
- Use setAbsolutePosition() for world-space positioning
- This ensures rotation and position work correctly together

Changes:
- Import Quaternion from @babylonjs/core
- Update createHandleMeshes(): decompose world matrix, set rotation,
  then use setAbsolutePosition()
- Rename updateHandlePositions() to updateHandleTransforms()
- Update updateHandleTransforms(): same rotation-then-position approach
- Add null check for _targetMesh in updateHandleTransforms()

Technical Details:
- computeWorldMatrix(true) gets complete transform including parent
- decompose() extracts pure rotation as quaternion (avoids gimbal lock)
- setAbsolutePosition() correctly handles world-space coords with rotation
- Order matters: rotation before position for correct transformation

Result:
 Handle box shapes visually tilt/rotate with mesh
 Handles remain correctly positioned on OBB
 Both wireframe and individual handles rotate together

Files modified:
- ResizeGizmoVisuals.ts: Handle rotation implementation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 07:36:57 -06:00
5fbf2b87c1 Implement OBB-based scaling for rotated meshes and simplify gizmo UX
Major improvements to ResizeGizmo rotation handling and interface:

1. **OBB (Oriented Bounding Box) Implementation**
   - Replace AABB with true OBB that rotates with mesh
   - Calculate 8 OBB corners in world space using mesh world matrix
   - Update bounding box wireframe to use OBB corners
   - Rewrite all handle generation (corner, edge, face) for OBB positioning
   - Handle normals now calculated from mesh center to handle position
   - Result: Bounding box and handles rotate with mesh, scaling follows local axes

2. **Simplify UX - Remove Edge Handles**
   - Remove TWO_AXIS mode from ResizeGizmoMode enum
   - Disable edge handles (green, two-axis) to reduce cognitive complexity
   - Keep only corner handles (blue, uniform) and face handles (red, single-axis)
   - Updated from 26 total handles to 14 handles (6 face + 8 corner)
   - All scaling capabilities still available through remaining handle types

3. **Fix Event Leak-Through (Hit Testing)**
   - Add getUtilityScene() method to ResizeGizmoManager
   - Configure XR pick predicate to exclude utility layer meshes (primary defense)
   - Filter utility layer in pointer observable (secondary defense)
   - Filter utility layer in click handler (tertiary defense)
   - Prevents gizmo handle events from leaking to main scene

4. **Documentation**
   - Add TODO.md documenting implementation and decisions
   - Document OBB implementation and edge handle removal
   - Track completed features and rationale

Files modified:
- ResizeGizmoVisuals.ts: OBB wireframe and corner calculation
- HandleGeometry.ts: OBB-based handle positioning for all types
- ResizeGizmoConfig.ts: Disable edge handles
- ResizeGizmoManager.ts: Add utility scene access
- ScalingCalculator.ts: Uniform two-axis scaling (distance-ratio)
- types.ts: Remove TWO_AXIS mode
- diagramMenuManager.ts: XR pick predicate filtering
- abstractController.ts: Pointer and click filtering
- TODO.md: Documentation of changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 07:06:06 -06:00
204ef670f9 Move ResizeGizmo handles inside bounding box for better selection
- Reverse padding direction in HandleGeometry:
  - Corner handles now positioned inward (add padding to min, subtract from max)
  - Edge handles now positioned inward (same reversal)
  - Face handles now positioned inward (same reversal)
- Remove padding from bounding box wireframe to match mesh bounds
- Handles are now 5% inside edges instead of 5% outside
- Improves handle selection reliability
- Prevents unwanted gizmo auto-hide when pointer moves to handles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 18:00:05 -06:00
26b48b26c8 Implement WebXR resize gizmo with virtual stick scaling and extract adapter to integration layer
- Implement comprehensive WebXR resize gizmo system with three handle types:
  - Corner handles: uniform scaling (all axes)
  - Edge handles: two-axis planar scaling
  - Face handles: single-axis scaling
- Use "virtual stick" metaphor for intuitive scaling:
  - Fixed-length projection from controller to handle intersection
  - Distance-ratio based scaling from mesh pivot point
  - Works naturally with controller rotation and movement
- Add world-space coordinate transformations for VR rig parenting
- Implement manual ray picking for utility layer handle detection
- Add motion controller initialization handling for grip button
- Fix color persistence bug in diagram entities:
  - DiagramEntityAdapter now uses toDiagramEntity() converter
  - Store color in mesh metadata for persistence
  - Add dependency injection for loose coupling
- Extract DiagramEntityAdapter to integration layer:
  - Move from src/gizmos/ResizeGizmo/ to src/integration/gizmo/
  - Add dependency injection for mesh-to-entity converter
  - Keep ResizeGizmo pure and reusable without diagram dependencies
- Add closest color matching for missing toolbox colors
- Handle size now relative to bounding box (20% of avg dimension)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 17:52:23 -06:00
02c08b35f2 Add comprehensive material sharing validation and diagnostics
Implemented extensive logging and validation to diagnose material sharing issues and prevent unnecessary material creation:

**Validation Added:**
- Pre-creation check: Verify tool meshes have materials before creating instances
- Early exit if tool mesh lacks material to prevent bad instances
- Post-creation validation in buildColor.ts to catch tool creation issues

**Enhanced Diagnostics:**
- Detailed debug logging for tool mesh lookup and instance creation
- Error logging with full context when material sharing fails
- Source mesh material validation for InstancedMesh
- Lists available tool meshes when lookup fails

**Statistics Tracking:**
- Tracks instances created vs materials shared
- Counts fallback material creations
- Logs sharing rate every 10 instances (target: 100%)
- Helps identify material sharing failures in production

**Expected Outcome:**
- 100% material sharing rate for tool-based entities
- Zero fallback material creations
- All instances inherit materials from tool templates
- Better draw call batching (same material = batched rendering)

This diagnostic infrastructure will identify:
1. Timing issues (tools not ready when entities created)
2. Tool mesh creation failures
3. BabylonJS InstancedMesh material inheritance issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 11:18:07 -06:00
bda0735c7f Add WebXR rendering mode toggle with 4 modes
Implemented a single button in the toolbox that cycles through four rendering modes:
1. Lightmap + Lighting - diffuseColor + lightmapTexture with lighting enabled
2. Emissive Texture - emissiveColor + emissiveTexture with lighting disabled (default)
3. Flat Color - emissiveColor only with lighting disabled
4. Diffuse + Lights - diffuseColor with two dynamic scene lights enabled

Features:
- Single clickable button displays current mode and cycles to next on click
- Automatically manages two scene lights (HemisphericLight + PointLight) for Diffuse + Lights mode
- UI materials (buttons, handles, labels) are excluded from mode changes to remain readable
- Button positioned below color grid with user-adjusted scaling
- Added comprehensive naming conventions documentation
- Updated inspector hotkey to Ctrl+Shift+I

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 10:36:03 -06:00
c7887d7d8f Optimize lightmap rendering using emissive texture approach
Changed from lightmapTexture with lighting enabled to emissiveTexture with lighting disabled for better performance. The new approach provides the same lighting illusion without expensive per-pixel lighting calculations.

- Added LightmapGenerator.ENABLED toggle for performance testing
- Updated buildColor.ts to use emissiveColor + emissiveTexture with disableLighting = true
- Updated buildMissingMaterial() to match new rendering approach
- Fixed buildTool.ts to access emissiveColor instead of diffuseColor for material color detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 09:44:56 -06:00
3f02fc7ea5 Implement lightmap-based rendering for performant lighting illusion
Replace emissive-only rendering with diffuse + lightmap system to achieve realistic lighting appearance without dynamic light overhead.

- Create LightmapGenerator class with canvas-based radial gradient generation
- Generate one lightmap per color (16 total) using top-left directional light simulation
- Cache lightmaps in static Map for reuse across all instances
- Preload all lightmaps at toolbox initialization for instant availability
- Update buildColor() to use diffuseColor + lightmapTexture instead of emissiveColor
- Update buildMissingMaterial() to use lightmap-based rendering
- Enable lighting calculations (disableLighting = false) to apply lightmaps

Lightmap details:
- 512x512 resolution RGBA textures
- Radial gradient: center (color × 1.5), mid (base color), edge (color × 0.3)
- Simulates top-left key light with smooth falloff
- Total memory: ~16 MB for all lightmaps
- Zero per-frame performance cost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 09:20:40 -06:00
100c5e612c Move exit XR button to toolbox class
Refactored exit XR button creation from rigplatform to toolbox for better organization and UI cohesion.

- Add setXR() methods to DiagramManager, DiagramMenuManager, and Toolbox to pass WebXRDefaultExperience after initialization
- Create setupXRButton() in Toolbox class that creates button when entering XR
- Position button at bottom-right of toolbox (x: 0.5, y: -0.35, z: 0)
- Use Y-axis rotation (Math.PI) for correct orientation within toolbox coordinate system
- Scale button to 0.2 for appropriate size
- Remove button creation code from rigplatform

Exit button now moves with toolbox and is logically grouped with other UI elements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 06:57:35 -06:00
d59c7b6e6e Enable per-instance edge rendering for hover effects
Changed EdgesRenderer to work on individual instances instead of source mesh to prevent all instances from highlighting when one is hovered.

- Remove edgesShareWithInstances flag (was causing all instances to highlight)
- Enable/disable edges directly on hovered instance
- Adjust edge width to 0.2 and color to pure white for cleaner appearance
- Remove metadata tracking in favor of checking edgesRenderer directly

This ensures only the specific hovered entity shows visual feedback while maintaining haptic feedback for all interactions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 05:49:54 -06:00
0ad61bdde9 Fix XR component positioning to appear in front of user
- Use camera.getDirection() instead of manual Euler angle calculation to properly account for camera transform hierarchy
- Negate forward offsets to position objects in -Z direction (user faces -Z by design)
- Replace expensive HighlightLayer hover effect with lightweight EdgesRenderer (20-50x faster)
- Add comprehensive debug logging for position calculations

The camera has a parent transform with 180° Y rotation, causing the user to face -Z in world space. Components now correctly position in front of the user when entering XR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 22:41:51 -06:00
4a9d7acc41 Optimize connection raycasting with position caching
Performance improvements:
- Added Vector3 position caching for connection endpoints
- Only update connections when meshes actually move (>0.001 units)
- Use DistanceSquared for efficient movement detection
- Replace inefficient vector length comparison

Impact:
- Static connections: 0 raycasts/second (was ~20/sec per connection)
- With 10 connections: 90-99% reduction in raycast operations
- Eliminates unnecessary curve geometry recreation

Implementation:
- Added _lastFromPosition and _lastToPosition caching
- Created hasConnectionMoved() method with tolerance threshold
- Reset cache on mesh removal and initial setup
- Clean up cache in disposal method

This dramatically reduces CPU usage in VR with multiple connections.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 21:46:57 -06:00
6ad04bb21a Refactor config naming and upgrade dependencies
Config changes:
- Renamed gridSnap to locationSnap for clarity
- Fixed configMenu to reference correct property
- Added debug logging to setAppConfig

Code cleanup:
- Removed commented duplicate exitXR call

Dependencies:
- Upgraded @babylonjs packages from 7.21.5 to 8.16.2
- Upgraded @mantine packages from 7.12.0 to 7.17.8

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 21:36:56 -06:00
293c74d7c1 Remove debug console.log from render loop
Removed console.log() from connectionPreview render observer that was
executing every frame during connection dragging. This eliminates I/O
blocking and stringification overhead in the critical VR render path.

Performance: Quick win for VR framerate improvement.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 21:29:24 -06:00
6d2049e1f6 Convert to unlit rendering and fix connection update error
Lighting changes:
- Disabled HemisphericLight in customEnvironment
- Changed all materials from diffuse to emissive colors
- Added disableLighting=true to all StandardMaterials
- Updated toolbox colors, diagram entities, and spinner

Bug fix:
- Fixed "Cannot read properties of undefined (reading 'pickedMesh')" error
- Added defensive check in DiagramObject.updateConnection()
- Now validates hit array has at least 2 elements before accessing

Materials now render at full brightness with unlit/flat shading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 21:16:29 -06:00
cf0f359921 Position UI components relative to camera on XR entry
When entering immersive mode, toolbox and input text view now position
themselves relative to the user's initial camera position:
- Toolbox: 0.5m ahead, 0.5m below, 0.2m to the left
- Input text view: 0.5m ahead, 0.5m below (centered)

Uses camera world Y position to ensure vertical offset is consistent
regardless of head pitch/tilt when entering XR.

Also added CLAUDE.md documentation for the codebase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:22:29 -06:00
58668443c4 Fix initialization errors when navigating to db/public/local
- Fix null reference error in buildColor.ts by initializing metadata.tools array
- Add physics engine availability check in buildRig to prevent PhysicsAggregate creation before engine is ready
- Remove duplicate scene initialization by eliminating redundant initializeEngine() call
- These fixes resolve WebGL shader compilation errors and prevent app crashes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 16:25:15 -05:00
9d5234b629 Added webxr exit button 2025-02-14 11:01:27 -06:00
5ce0c9ce4f Changed menu to be consistent between mini and main size. 2024-11-22 09:25:22 -05:00
8c04b40d03 Added Branding + Auth. 2024-08-30 14:57:29 -05:00
cdf59db5b6 Updated config page. 2024-08-30 14:56:13 -05:00
f2b9e78e45 Updated config page. 2024-08-30 12:43:19 -05:00
4e6c3a63d0 Updated config page. 2024-08-30 12:43:19 -05:00
e69d008bfa Added 404 handler, changed page db update. 2024-08-30 12:43:19 -05:00
5d3cad0def Reintegrated VR compnent. 2024-08-30 12:43:19 -05:00
4f39030ed4 Disabled service worker, enhanced management console. 2024-08-30 12:43:19 -05:00
2397ddcd4c Updated UI to use Mantine. 2024-08-30 12:43:19 -05:00
b9152678b8 Removed dead code. 2024-08-30 12:43:19 -05:00
a9c8d3dbad Removed dead code. 2024-08-30 12:43:19 -05:00
60758ed84d Removed dead code. 2024-08-30 12:43:19 -05:00
53ca47d63e
Update node.js.yml 2024-08-30 12:18:10 -05:00
216 changed files with 25687 additions and 4924 deletions

75
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,75 @@
name: Build and Deploy
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: linux_amd64
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
timeout-minutes: 5
- name: Build Front End
run: npm run build
timeout-minutes: 10
env:
NODE_OPTIONS: '--max-old-space-size=4096'
VITE_AUTH0_CLIENTID: ${{ secrets.VITE_AUTH0_CLIENTID }}
VITE_AUTH0_DOMAIN: ${{ secrets.VITE_AUTH0_DOMAIN }}
- name: Stop Service
run: |
sudo rc-service immersive stop || true
- name: Deploy to /opt/immersive
run: |
# Ensure group write so we can delete old files
sudo chmod -R g+w /opt/immersive || true
# Remove old files except data directory and env file
find /opt/immersive -mindepth 1 -maxdepth 1 ! -name 'data' ! -name '.env.production' -exec rm -rf {} +
# Copy built files to target
cp -r . /opt/immersive/
# Remove unnecessary directories
rm -rf /opt/immersive/.git /opt/immersive/.github
# Set permissions on start.sh and ensure group write for future deploys
chmod +x /opt/immersive/start.sh
sudo chmod -R g+w /opt/immersive
# Set ownership to immersive user
sudo chown -R immersive:immersive /opt/immersive
- name: Create Environment File
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
run: |
# Create .env.production with secrets (only accessible by immersive user)
echo "# Auto-generated by CI/CD - Do not edit manually" > /opt/immersive/.env.production
echo "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}" >> /opt/immersive/.env.production
echo "CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID}" >> /opt/immersive/.env.production
echo "CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}" >> /opt/immersive/.env.production
echo "NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY}" >> /opt/immersive/.env.production
# Secure the environment file
sudo chown immersive:immersive /opt/immersive/.env.production
sudo chmod 600 /opt/immersive/.env.production
- name: Start Service
run: |
sudo rc-service immersive start

View File

@ -2,9 +2,9 @@ name: Node.js CI
on:
push:
branches: [ "main" ]
branches: [ "deepdiagram" ]
pull_request:
branches: [ "main" ]
branches: [ "deepdiagram" ]
jobs:
build:

View File

@ -5,9 +5,9 @@ name: Node.js Github Side
on:
push:
branches: [ "main" ]
branches: [ "deepdiagram" ]
pull_request:
branches: [ "main" ]
branches: [ "deepdiagram" ]
jobs:
build:

2
.gitignore vendored
View File

@ -25,3 +25,5 @@ dist-ssr
# Local Netlify folder
.netlify
/data/
/.env.production

280
ALPINE_SERVICE.md Normal file
View File

@ -0,0 +1,280 @@
# Alpine Linux Service Setup
This guide covers installing and running Immersive as a service on Alpine Linux using OpenRC.
## Prerequisites
```bash
# Update packages
apk update
# Install Node.js 18+ and npm
apk add nodejs npm
# Install build dependencies (required for native modules like leveldown)
apk add python3 make g++ git
# Verify Node version (must be >= 18)
node --version
```
## Create Service User
Create a dedicated user to run the service (security best practice):
```bash
# Create immersive group and user (no login shell, no home directory)
addgroup -S immersive
adduser -S -G immersive -H -s /sbin/nologin immersive
# Create directories with proper ownership
mkdir -p /opt/immersive
mkdir -p /var/log/immersive
mkdir -p /var/run/immersive
chown -R immersive:immersive /opt/immersive
chown -R immersive:immersive /var/log/immersive
chown -R immersive:immersive /var/run/immersive
```
## Installation
```bash
# Create application directory
mkdir -p /opt/immersive
cd /opt/immersive
# Clone or copy the application
git clone <your-repo-url> .
# OR copy files manually
# Install dependencies
npm ci --production=false
# Build the application
NODE_OPTIONS='--max-old-space-size=4096' npm run build
# Copy Havok physics WASM (if not already done by build)
npm run havok
# Create data directory for PouchDB
mkdir -p /opt/immersive/data
# Set ownership to immersive user
chown -R immersive:immersive /opt/immersive
```
## Start Script
The `start.sh` script is included in the repository. After deployment, ensure it's executable:
```bash
chmod +x /opt/immersive/start.sh
```
The script sets up the environment and starts the Node.js server, logging output to `/var/log/immersive/`.
## OpenRC Service
Create `/etc/init.d/immersive`:
```bash
#!/sbin/openrc-run
name="immersive"
description="Immersive WebXR Diagramming Application"
command="/opt/immersive/start.sh"
command_user="immersive:immersive"
command_background="yes"
pidfile="/var/run/immersive/immersive.pid"
directory="/opt/immersive"
output_log="/var/log/immersive/app.log"
error_log="/var/log/immersive/error.log"
depend() {
need net
after firewall
}
start_pre() {
checkpath --directory --owner immersive:immersive --mode 0755 /var/log/immersive
checkpath --directory --owner immersive:immersive --mode 0755 /var/run/immersive
checkpath --file --owner immersive:immersive --mode 0644 /var/log/immersive/app.log
checkpath --file --owner immersive:immersive --mode 0644 /var/log/immersive/error.log
}
```
Make it executable and enable:
```bash
chmod +x /etc/init.d/immersive
rc-update add immersive default
```
## Service Management
```bash
# Start the service
rc-service immersive start
# Stop the service
rc-service immersive stop
# Restart the service
rc-service immersive restart
# Check status
rc-service immersive status
# View logs
tail -f /var/log/immersive/app.log
tail -f /var/log/immersive/error.log
```
## Log Rotation
Create `/etc/logrotate.d/immersive`:
```
/var/log/immersive/*.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
create 0644 immersive immersive
postrotate
rc-service immersive restart > /dev/null 2>&1 || true
endscript
}
```
Install logrotate if not present:
```bash
apk add logrotate
```
## Environment Variables
Create `/opt/immersive/.env.production` for production settings:
```bash
# Server
NODE_ENV=production
PORT=3001
# Auth0 (if using authentication)
# VITE_AUTH0_DOMAIN=your-domain.auth0.com
# VITE_AUTH0_CLIENT_ID=your-client-id
# Database sync endpoint (optional)
# VITE_SYNCDB_ENDPOINT=https://your-couchdb-server.com
```
## Firewall (if using iptables)
```bash
# Allow port 3001
iptables -A INPUT -p tcp --dport 3001 -j ACCEPT
# Save rules
rc-service iptables save
```
## Reverse Proxy (Optional)
If using nginx as a reverse proxy:
```bash
apk add nginx
```
Create `/etc/nginx/http.d/immersive.conf`:
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
```
Enable and start nginx:
```bash
rc-update add nginx default
rc-service nginx start
```
## Gitea CI/CD Runner (Optional)
If using a Gitea Actions runner to deploy, grant the runner user write access to `/opt/immersive`:
```bash
# Add gitea-runner to immersive group
adduser gitea-runner immersive
# Set group write permissions on /opt/immersive
chmod -R g+w /opt/immersive
# Ensure new files inherit group ownership
chmod g+s /opt/immersive
# Allow runner to manage the service
# Add to /etc/sudoers.d/gitea-runner:
echo 'gitea-runner ALL=(ALL) NOPASSWD: /sbin/rc-service immersive *' > /etc/sudoers.d/gitea-runner
echo 'gitea-runner ALL=(ALL) NOPASSWD: /bin/chown -R immersive\:immersive /opt/immersive' >> /etc/sudoers.d/gitea-runner
chmod 440 /etc/sudoers.d/gitea-runner
```
The GitHub Actions workflow in `.github/workflows/build.yml` will handle deployment automatically on push to main.
## Troubleshooting
**Service fails to start:**
```bash
# Check logs
cat /var/log/immersive/error.log
# Run manually as immersive user to see errors
su -s /bin/sh immersive -c "cd /opt/immersive && NODE_ENV=production node server.js"
```
**Native module errors (leveldown):**
```bash
# Rebuild native modules
cd /opt/immersive
npm rebuild leveldown
```
**Permission issues:**
```bash
# Ensure proper ownership (must be immersive user)
chown -R immersive:immersive /opt/immersive
chown -R immersive:immersive /var/log/immersive
chown -R immersive:immersive /var/run/immersive
chmod -R 755 /opt/immersive
```
**Port already in use:**
```bash
# Find process using port 3001
lsof -i :3001
# Or
netstat -tlnp | grep 3001
```

137
CLAUDE.md Normal file
View File

@ -0,0 +1,137 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is "immersive" - a WebXR/VR diagramming application built with BabylonJS and React. It allows users to create and interact with 3D diagrams in both standard web browsers and VR environments, with real-time collaboration via PouchDB sync.
## Build and Development Commands
### Development
- `npm run dev` - Start Vite dev server on port 3001 (DO NOT USE per user instructions)
- `npm run build` - Build production bundle (includes version bump)
- `npm run preview` - Preview production build on port 3001
- `npm test` - Run tests with Vitest
- `npm run socket` - Start WebSocket server for collaboration (port 8080)
- `npm run serverBuild` - Compile TypeScript server code
- `npm run havok` - Copy Havok physics WASM files to Vite deps
### Testing
- Run all tests: `npm test`
- No single test command is configured; tests use Vitest
## Architecture
### Core Technologies
- **BabylonJS 8.x**: 3D engine with WebXR support and Havok physics
- **React + Mantine**: UI framework for 2D interface and settings
- **PouchDB**: Client-side database with CouchDB sync for collaboration
- **Auth0**: Authentication provider
- **Vite**: Build tool and dev server
### Key Architecture Patterns
#### Singleton Scene Management
The application uses a singleton pattern for the BabylonJS Scene via `DefaultScene` (src/defaultScene.ts). Always access the scene through `DefaultScene.Scene` rather than creating new instances.
#### Observable-Based Event System
The application heavily uses BabylonJS Observables for event handling:
- **DiagramManager.onDiagramEventObservable**: Central hub for diagram entity changes
- **DiagramManager.onUserEventObservable**: User position/state updates for multiplayer
- **AppConfig.onConfigChangedObservable**: Application settings changes
- **controllerObservable**: VR controller input events
Event observers use a mask system (`DiagramEventObserverMask`) to distinguish:
- `FROM_DB`: Events coming from database sync (shouldn't trigger database writes)
- `TO_DB`: Events that should be persisted to database
#### Diagram Entity System
All 3D objects in the scene are represented by `DiagramEntity` types (src/diagram/types/diagramEntity.ts):
- Entities have a template reference (e.g., `#image-template`)
- Managed by `DiagramManager` which maintains a Map of `DiagramObject` instances
- Changes propagate through the Observable system to database and other clients
#### VR Controller Architecture
Controllers inherit from `AbstractController` with specialized implementations:
- `LeftController`: Menu interactions, navigation
- `RightController`: Object manipulation, selection
- Controllers communicate via `controllerObservable` with `ControllerEvent` messages
- `Rigplatform` manages the player rig and handles locomotion
#### Database & Sync
- `PouchdbPersistenceManager` (src/integration/database/pouchdbPersistenceManager.ts) handles all persistence
- Supports optional encryption via `Encryption` class
- Syncs to remote CouchDB via proxy (configured in vite.config.ts)
- URL pattern `/db/public/:db` or `/db/private/:db` determines database name
- Uses `presence.ts` for broadcasting user positions over WebSocket
### Project Structure
- `src/vrcore/`: Engine initialization and core VR setup
- `src/controllers/`: VR controller implementations and input handling
- `src/diagram/`: 3D diagram entities, management, and scene interaction
- `src/integration/`: Database sync, encryption, and presence system
- `src/menus/`: In-VR 3D menus (not React components)
- `src/objects/`: Reusable 3D objects (buttons, handles, avatars)
- `src/react/`: React UI components for 2D interface
- `src/util/`: Shared utilities and configuration
- `server/`: WebSocket server for real-time presence
### Configuration System
Two configuration systems exist (being migrated):
1. **AppConfig class** (src/util/appConfig.ts): Observable-based config with typed properties
2. **ConfigType** (bottom of appConfig.ts): Legacy localStorage-based config
Settings include snapping values, physics toggles, fly mode, and turn snap angles.
## Important Development Notes
### Proxy Configuration
The dev and preview servers proxy certain routes to production:
- `/sync/*` - Database sync endpoint
- `/create-db` - Database creation
- `/api/images` - Image uploads
### Physics System
- Uses Havok physics engine (requires WASM file via `npm run havok`)
- Physics can be enabled/disabled via AppConfig
- `customPhysics.ts` provides helper functions
### WebGPU Support
The engine initializer supports both WebGL and WebGPU backends via the `useWebGpu` parameter.
### Encryption
Databases can be optionally encrypted. The `Encryption` class handles AES encryption with password-derived keys. Salt is stored in metadata document.
### Environment Variables
- `VITE_USER_ENDPOINT`: User authentication endpoint
- `VITE_SYNCDB_ENDPOINT`: Remote database sync endpoint
Check `.env.local` for local configuration.
## Naming Conventions
### Tool and Material Naming
**Material Names:** Materials follow the pattern `material-{color}` where `{color}` is the hex color string (e.g., `material-#ff0000` for red).
**Tool Mesh Names:** Tools use the pattern `tool-{toolType}-{color}`:
- Example: `tool-BOX-#ff0000` (red box tool)
- ToolTypes: `BOX`, `SPHERE`, `CYLINDER`, `CONE`, `PLANE`, `PERSON`
**Tool Instance Names:** `tool-instance-{toolType}-{color}` (e.g., `tool-instance-BOX-#ff0000`)
**Implementation details:**
- 16 predefined toolbox colors (see docs/NAMING_CONVENTIONS.md)
- Materials created in `src/toolbox/functions/buildColor.ts`
- Tool meshes created in `src/toolbox/functions/buildTool.ts`
- When extracting colors from materials, use: `emissiveColor || diffuseColor` (priority order)
### Rendering Modes
Three rendering modes affect material properties:
1. **Lightmap with Lighting**: Uses `diffuseColor` + `lightmapTexture` (expensive)
2. **Unlit with Emissive Texture** (default): Uses `emissiveColor` + `emissiveTexture` (lightmap)
3. **Flat Emissive**: Uses only `emissiveColor` (fastest)
See `src/util/renderingMode.ts` and `src/util/lightmapGenerator.ts` for implementation.

403
EXPRESS_API_PLAN.md Normal file
View File

@ -0,0 +1,403 @@
# Express.js API Server Plan
## Goal
Add an Express.js backend server to handle API routes (starting with Claude API), with support for either combined or split deployment.
## Advantages Over Next.js Migration
- **Minimal frontend changes** - only API URL configuration
- **No routing changes** - keep react-router-dom as-is
- **Flexible deployment** - combined or split frontend/backend
- **Already partially exists** - `server.js` in root has Express + vite-express scaffolding
## Deployment Options
### Option A: Combined (Single Server)
```
Express Server (vite-express)
├── Serves static files from dist/
└── Handles /api/* routes
```
- Simpler setup, one deployment
- Good for: VPS, Railway, Fly.io, DigitalOcean App Platform
### Option B: Split (Separate Hosts)
```
Static Host (CDN) API Server (Node.js)
├── Cloudflare Pages ├── Railway
├── Netlify ├── Fly.io
├── Vercel ├── AWS Lambda
└── S3 + CloudFront └── Any VPS
Serves dist/ Handles /api/*
```
- Better scalability, cheaper static hosting
- Good for: High traffic, global CDN distribution
---
## Current State
### Existing `server.js` (incomplete)
```javascript
import express from "express";
import ViteExpress from "vite-express";
import dotenv from "dotenv";
import expressProxy from "express-http-proxy";
dotenv.config();
const app = express();
app.use("/api", expressProxy("local.immersiveidea.com"));
ViteExpress.listen(app, process.env.PORT || 3001, () => console.log("Server is listening..."));
```
### Missing Dependencies
The following packages are imported but not in package.json:
- `express`
- `vite-express`
- `express-http-proxy`
- `dotenv`
---
## Implementation Plan
### Phase 1: Install Dependencies
```bash
npm install express vite-express dotenv cors
```
- `express` - Web framework
- `vite-express` - Vite integration for combined deployment
- `dotenv` - Environment variable loading
- `cors` - Cross-origin support for split deployment
### Phase 2: Create API Routes Structure
Create a modular API structure:
```
server/
├── server.js # Existing WebSocket server (keep as-is)
├── api/
│ ├── index.js # Main API router
│ └── claude.js # Claude API proxy route
```
### Phase 3: Update Root `server.js`
Replace the current incomplete server.js with:
```javascript
import express from "express";
import ViteExpress from "vite-express";
import cors from "cors";
import dotenv from "dotenv";
import apiRoutes from "./server/api/index.js";
dotenv.config();
const app = express();
// CORS configuration for split deployment
// In combined mode, same-origin requests don't need CORS
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [];
if (allowedOrigins.length > 0) {
app.use(cors({
origin: allowedOrigins,
credentials: true,
}));
}
app.use(express.json());
// API routes
app.use("/api", apiRoutes);
// Check if running in API-only mode (split deployment)
const apiOnly = process.env.API_ONLY === "true";
if (apiOnly) {
// API-only mode: no static file serving
app.listen(process.env.PORT || 3000, () => {
console.log(`API server running on port ${process.env.PORT || 3000}`);
});
} else {
// Combined mode: Vite handles static files + SPA
ViteExpress.listen(app, process.env.PORT || 3001, () => {
console.log(`Server running on port ${process.env.PORT || 3001}`);
});
}
```
### Phase 4: Create API Router
**`server/api/index.js`**:
```javascript
import { Router } from "express";
import claudeRouter from "./claude.js";
const router = Router();
// Claude API proxy
router.use("/claude", claudeRouter);
// Health check
router.get("/health", (req, res) => {
res.json({ status: "ok" });
});
export default router;
```
**`server/api/claude.js`**:
```javascript
import { Router } from "express";
const router = Router();
const ANTHROPIC_API_URL = "https://api.anthropic.com";
router.post("/*", async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: "API key not configured" });
}
// Get the path after /api/claude (e.g., /v1/messages)
const path = req.params[0] || req.path;
try {
const response = await fetch(`${ANTHROPIC_API_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(req.body),
});
const data = await response.json();
res.status(response.status).json(data);
} catch (error) {
console.error("Claude API error:", error);
res.status(500).json({ error: "Failed to proxy request to Claude API" });
}
});
export default router;
```
### Phase 5: Update Vite Config
Remove the Claude proxy from `vite.config.ts` since Express handles it now.
**Before** (lines 41-56):
```javascript
'^/api/claude': {
target: 'https://api.anthropic.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/claude/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
const apiKey = env.ANTHROPIC_API_KEY;
// ...
});
}
}
```
**After**: Remove this block entirely. The Express server handles `/api/claude/*`.
Keep the other proxies (`/sync/*`, `/create-db`, `/api/images`) - they still proxy to deepdiagram.com in dev mode.
### Phase 6: Add API URL Configuration (for Split Deployment)
Create a utility to get the API base URL:
**`src/util/apiConfig.ts`**:
```typescript
// API base URL - empty string for same-origin (combined deployment)
// Set VITE_API_URL for split deployment (e.g., "https://api.yourdomain.com")
export const API_BASE_URL = import.meta.env.VITE_API_URL || '';
export function apiUrl(path: string): string {
return `${API_BASE_URL}${path}`;
}
```
**Update `src/react/services/diagramAI.ts`**:
```typescript
import { apiUrl } from '../../util/apiConfig';
// Change from:
const response = await fetch('/api/claude/v1/messages', { ... });
// To:
const response = await fetch(apiUrl('/api/claude/v1/messages'), { ... });
```
This change is backward-compatible:
- **Combined deployment**: `VITE_API_URL` is empty, calls go to same origin
- **Split deployment**: `VITE_API_URL=https://api.example.com`, calls go to API server
### Phase 7: Update package.json Scripts
```json
"scripts": {
"dev": "node server.js",
"build": "node versionBump.js && vite build",
"start": "NODE_ENV=production node server.js",
"start:api": "API_ONLY=true node server.js",
"test": "vitest",
"socket": "node server/server.js",
"serverBuild": "cd server && tsc"
}
```
**Changes:**
- `dev`: Runs Express + vite-express (serves Vite in dev mode)
- `start`: Combined mode - serves dist/ + API
- `start:api`: API-only mode for split deployment
- Removed `preview` (use `start` instead)
---
## File Changes Summary
| Action | File | Description |
|--------|------|-------------|
| Modify | `package.json` | Add dependencies, update scripts |
| Modify | `server.js` | Full Express server with CORS + API routes |
| Create | `server/api/index.js` | Main API router |
| Create | `server/api/claude.js` | Claude API proxy endpoint |
| Create | `src/util/apiConfig.ts` | API URL configuration utility |
| Modify | `src/react/services/diagramAI.ts` | Use apiUrl() for API calls |
| Modify | `vite.config.ts` | Remove `/api/claude` proxy block |
---
## How vite-express Works
`vite-express` is a simple integration that:
1. **Development**: Runs Vite's dev server as middleware, providing HMR
2. **Production**: Serves the built `dist/` folder as static files
This means:
- One server handles both API and frontend
- No CORS issues (same origin)
- HMR works in development
- Production-ready with `vite build`
---
## Production Deployment
### Option A: Combined Deployment
Single server handles both frontend and API:
```bash
# Build frontend
npm run build
# Start combined server (serves dist/ + API)
npm run start
```
**Environment variables (.env)**:
```bash
PORT=3001
ANTHROPIC_API_KEY=sk-ant-...
```
The Express server will:
1. Handle `/api/*` routes directly
2. Serve static files from `dist/`
3. Fall back to `dist/index.html` for SPA routing
### Option B: Split Deployment
Separate hosting for frontend (CDN) and API (Node server):
**API Server:**
```bash
# Start API-only server
npm run start:api
```
**Environment variables (.env for API server)**:
```bash
PORT=3000
API_ONLY=true
ANTHROPIC_API_KEY=sk-ant-...
ALLOWED_ORIGINS=https://your-frontend.com,https://www.your-frontend.com
```
**Frontend (Static Host):**
```bash
# Build with API URL configured
VITE_API_URL=https://api.yourdomain.com npm run build
# Deploy dist/ to your static host (Cloudflare Pages, Netlify, etc.)
```
**Environment variables (.env.production for frontend build)**:
```bash
VITE_API_URL=https://api.yourdomain.com
```
### Deployment Examples
| Deployment | Frontend | API Server | Cost |
|------------|----------|------------|------|
| Combined | Railway | (same) | ~$5/mo |
| Combined | Fly.io | (same) | Free tier |
| Split | Cloudflare Pages (free) | Railway ($5/mo) | ~$5/mo |
| Split | Netlify (free) | Fly.io (free) | Free |
| Split | Vercel (free) | AWS Lambda | Pay-per-use |
---
## Future API Routes
To add more API routes, create new files in `server/api/`:
```javascript
// server/api/index.js
import claudeRouter from "./claude.js";
import imagesRouter from "./images.js"; // future
import authRouter from "./auth.js"; // future
router.use("/claude", claudeRouter);
router.use("/images", imagesRouter);
router.use("/auth", authRouter);
```
---
## Migration Order
1. `npm install express vite-express dotenv cors`
2. Create `server/api/index.js`
3. Create `server/api/claude.js`
4. Create `src/util/apiConfig.ts`
5. Update `src/react/services/diagramAI.ts` to use `apiUrl()`
6. Update `server.js` (root) with full Express + CORS setup
7. Remove `/api/claude` proxy from `vite.config.ts`
8. Update `package.json` scripts
9. Test combined: `npm run dev` and verify Claude API works
10. (Optional) Test split: Set `VITE_API_URL` and `API_ONLY=true`
---
## Notes
- **WebSocket server unchanged**: `server/server.js` (port 8080) runs separately
- **Minimal frontend changes**: Only `diagramAI.ts` updated to use `apiUrl()`
- **Environment variables**: `ANTHROPIC_API_KEY` already in `.env.local`
- **Node version**: Requires Node 18+ for native `fetch`
- **CORS**: Only enabled when `ALLOWED_ORIGINS` is set (split deployment)
- **Backward compatible**: Works as combined deployment by default

30
LICENSE.txt Normal file
View File

@ -0,0 +1,30 @@
Permissions Conditions Limitations
Commercial use
Distribution
Modification
Private use
License and copyright notice
Liability
Warranty
MIT License
Copyright (c) [2024] [Michael Mainguy]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

167
NEXT_MIGRATION_PLAN.md Normal file
View File

@ -0,0 +1,167 @@
# Vite to Next.js Migration Plan
## Goal
Migrate from Vite to Next.js App Router to get proper API route support, with minimal changes to existing code.
## Configuration
- **Router**: App Router with `'use client'` on all pages
- **Rendering**: CSR only (no SSR) - simplifies migration since BabylonJS can't SSR
- **API Routes**: Claude API now, structured for future expansion
- **External Proxies**: Keep sync/create-db/images as Next.js rewrites to deepdiagram.com
---
## Phase 1: Setup (No Breaking Changes)
### 1.1 Install Next.js
```bash
npm install next
```
### 1.2 Create `next.config.js`
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{ source: '/sync/:path*', destination: 'https://www.deepdiagram.com/sync/:path*' },
{ source: '/create-db', destination: 'https://www.deepdiagram.com/create-db' },
{ source: '/api/images', destination: 'https://www.deepdiagram.com/api/images' },
];
},
webpack: (config, { isServer }) => {
config.experiments = { ...config.experiments, asyncWebAssembly: true };
return config;
},
};
module.exports = nextConfig;
```
### 1.3 Update `tsconfig.json`
Add path alias:
```json
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
```
---
## Phase 2: Create New Files
### 2.1 `src/react/providers.tsx` (extract from webApp.tsx)
- Move Auth0Provider and FeatureProvider wrapping here
- Add `'use client'` directive
- Handle window/document checks for SSR safety
### 2.2 `app/layout.tsx`
- Root layout with html/body tags
- Metadata (title, favicon from current index.html)
- Import global CSS
### 2.3 `app/globals.css`
```css
@import '../src/react/styles.css';
@import '@mantine/core/styles.css';
```
### 2.4 `app/api/claude/[...path]/route.ts`
- POST handler that proxies to api.anthropic.com
- Injects `ANTHROPIC_API_KEY` from env
- Adds `x-api-key` and `anthropic-version` headers
### 2.5 Page files (all with `'use client'`)
| Route | File | Component |
|-------|------|-----------|
| `/` | `app/page.tsx` | About |
| `/documentation` | `app/documentation/page.tsx` | Documentation |
| `/examples` | `app/examples/page.tsx` | Examples |
| `/pricing` | `app/pricing/page.tsx` | Pricing |
| `/db/[visibility]/[db]` | `app/db/[visibility]/[db]/page.tsx` | VrExperience |
| 404 | `app/not-found.tsx` | NotFound |
### 2.6 `src/react/components/ProtectedPage.tsx`
- Next.js version of route protection
- Uses `useRouter` from `next/navigation` for redirects
---
## Phase 3: Modify Existing Files
### 3.1 `src/react/pages/vrExperience.tsx`
**Changes:**
- Remove `useParams()` from react-router-dom
- Accept `visibility` and `db` as props instead
- Replace `useNavigate()` with `useRouter()` from `next/navigation`
### 3.2 `src/react/pageHeader.tsx`
**Changes:**
- Replace `import {Link} from "react-router-dom"` with `import Link from "next/link"`
- Change `to={item.href}` to `href={item.href}` on Link components
### 3.3 `src/react/marketing/about.tsx`
**Changes:**
- Replace `useNavigate()` with `useRouter()` from `next/navigation`
- Change `navigate('/path')` to `router.push('/path')`
### 3.4 `package.json`
```json
"scripts": {
"dev": "next dev -p 3001",
"build": "node versionBump.js && next build",
"start": "next start -p 3001",
"test": "vitest",
"socket": "node server/server.js",
"serverBuild": "cd server && tsc"
}
```
---
## Phase 4: Delete Old Files
| File | Reason |
|------|--------|
| `vite.config.ts` | Replaced by next.config.js |
| `index.html` | Next.js generates HTML |
| `src/webApp.ts` | Entry point no longer needed |
| `src/react/webRouter.tsx` | Replaced by app/ routing |
| `src/react/webApp.tsx` | Logic moved to providers.tsx |
| `src/react/components/ProtectedRoute.tsx` | Replaced by ProtectedPage.tsx |
---
## Critical Files to Modify
- `src/react/pages/vrExperience.tsx` - useParams -> props
- `src/react/pageHeader.tsx` - react-router Link -> Next.js Link
- `src/react/marketing/about.tsx` - useNavigate -> useRouter
- `src/react/webApp.tsx` - extract to providers.tsx
- `package.json` - scripts update
- `tsconfig.json` - path aliases
---
## Migration Order
1. Install next, create next.config.js
2. Update tsconfig.json
3. Create app/globals.css
4. Create src/react/providers.tsx
5. Create app/layout.tsx
6. Create app/api/claude/[...path]/route.ts
7. Create src/react/components/ProtectedPage.tsx
8. Modify vrExperience.tsx (accept props)
9. Create all app/*/page.tsx files
10. Modify pageHeader.tsx (Next.js Link)
11. Modify about.tsx (useRouter)
12. Update package.json scripts
13. Delete old files (vite.config.ts, index.html, webApp.ts, webRouter.tsx, webApp.tsx)
14. Test all routes
---
## Notes
- **Havok WASM**: Move `HavokPhysics.wasm` to `public/` folder
- **react-router-dom**: Can be removed from dependencies after migration
- **vite devDependencies**: Can be removed (vite, vite-plugin-cp)

224
ROADMAP.md Normal file
View File

@ -0,0 +1,224 @@
# Immersive - Product Roadmap
## Vision
Transform immersive into an accessible, intuitive WebXR diagramming platform that delivers a frictionless onboarding experience and sustainable growth path.
---
## Phase 1: Onboarding & User Experience (Q1 2025)
### 1.1 Frictionless Entry
**Goal:** Reduce barriers to entry for new users
- [ ] Redesign landing page to clearly guide users to immersive experience
- [ ] Create one-click "Enter VR" / "Try Demo" workflow
- [ ] Optimize initial load time and progressive loading
- [ ] Add clear device compatibility messaging (desktop/VR)
- [ ] Implement guest mode with no sign-in required for basic exploration
### 1.2 Marketing Content
**Goal:** Communicate value proposition effectively
- [ ] Create 3-5 demo videos showcasing key features (30-60 seconds each)
- Creating a basic diagram
- VR interaction showcase
- Collaboration features
- Template usage
- [ ] Develop tutorial video (2-3 minutes) explaining core workflows
- [ ] Autoplay video carousel on landing page
- [ ] Write marketing copy for landing page
- Hero section with clear value proposition
- Feature highlights
- Use case examples
- Call-to-action
### 1.3 In-Experience Tutorial
**Goal:** Replace external tutorial with immersive learning
- [ ] Remove existing external tutorial system
- [ ] Design in-VR tutorial experience with interactive steps
- [ ] Implement progressive disclosure (teach as users interact)
- [ ] Add contextual tooltips and hints in 3D space
- [ ] Create "first-time user" detection and guided walkthrough
- [ ] Add skip/replay tutorial options
### 1.4 Template System
**Goal:** Provide starting points for new users
- [ ] Design template/example diagram system
- [ ] Create 5-10 starter templates:
- Simple organizational chart
- Project workflow diagram
- Concept mapping example
- Architecture diagram
- Spatial layout example
- [ ] Build template browser UI (2D and VR)
- [ ] Implement "New from Template" workflow
- [ ] Add template preview/thumbnail generation
---
## Phase 2: Collaboration & Sync (Q2 2025)
### 2.1 Cross-Device Sharing
**Goal:** Enable seamless content sharing between desktop and Quest
- [ ] Research device-to-device sync options (WebRTC, local network)
- [ ] Design sync architecture without backend dependency
- [ ] Implement user content sync for signed-in users
- [ ] Add fallback to server-based sync when needed
- [ ] Create device pairing UI/workflow
- [ ] Test sync reliability across desktop ↔ Quest
- [ ] Add conflict resolution for simultaneous edits
---
## Phase 3: Immersion & Environment (Q2-Q3 2025)
### 3.1 Audio Integration
**Goal:** Enhance presence with ambient soundscapes
- [ ] Source/create ambient audio assets
- Nature sounds (birds, wind, water)
- Office ambience
- Abstract/focus music
- [ ] Implement spatial audio system
- [ ] Add audio settings (volume, on/off, environment selection)
- [ ] Create audio manager for seamless transitions
- [ ] Add positional audio for collaboration (optional user voices)
### 3.2 Environment System
**Goal:** Provide varied immersive environments
- [ ] Design environment switching architecture
- [ ] Create environment presets:
- Outdoor/nature scene
- Modern office
- Abstract/minimal space
- Workshop/studio
- [ ] Implement skybox and lighting variations
- [ ] Build environment selector UI (2D and VR)
- [ ] Optimize environment assets for performance
- [ ] Add environment-specific audio pairing
---
## Phase 4: User Feedback & Polish (Q3 2025)
### 4.1 In-VR Feedback Mechanism
**Goal:** Enable users to provide feedback without leaving VR
- [ ] Design in-VR feedback form/interface
- [ ] Implement voice-to-text option (VR accessibility)
- [ ] Add screenshot/recording attachment capability
- [ ] Create feedback submission backend
- [ ] Build feedback review dashboard
- [ ] Add "Report Bug" quick action in VR menu
### 4.2 Keyboard Improvements
**Goal:** Improve text input experience
- [ ] Test system keyboard integration (Quest/desktop)
- [ ] Evaluate custom keyboard vs. native keyboard UX
- [ ] Implement system keyboard fallback where supported
- [ ] Optimize keyboard positioning in VR space
- [ ] Add keyboard shortcuts for power users (desktop)
---
## Phase 5: Growth & Monetization (Q4 2025)
### 5.1 Marketing Roadmap
**Goal:** Build sustainable user acquisition
- [ ] Define target audience segments
- Educators
- Remote teams
- Designers/architects
- Knowledge workers
- [ ] Create content marketing strategy
- Blog posts on use cases
- Social media showcase
- Community building (Discord/Reddit)
- [ ] Develop SEO optimization plan
- [ ] Plan partnership outreach (VR communities, productivity tools)
- [ ] Create referral/sharing incentives
- [ ] Build analytics dashboard for user metrics
### 5.2 Monetization Strategy
**Goal:** Establish path to sustainability
**Potential Revenue Streams:**
- [ ] Freemium model research
- Free tier: Limited diagrams, basic features
- Pro tier: Unlimited diagrams, advanced features, collaboration
- [ ] Team/Enterprise pricing
- Private deployment options
- Admin controls
- Priority support
- [ ] Template marketplace
- Premium templates
- Community submissions (revenue share)
- [ ] Educational licensing
- Institutional pricing
- Classroom management features
**Implementation:**
- [ ] Define pricing tiers and feature gates
- [ ] Integrate payment processing (Stripe)
- [ ] Build subscription management UI
- [ ] Implement feature flags for tier differentiation
- [ ] Create upgrade prompts and conversion flow
- [ ] Add usage analytics for pricing optimization
---
## Success Metrics
### Phase 1-2 (Onboarding)
- Time to first diagram creation < 2 minutes
- Tutorial completion rate > 60%
- Return user rate (7-day) > 30%
### Phase 3-4 (Engagement)
- Average session duration > 15 minutes
- User satisfaction score > 4/5
- Feedback submission rate (active users) > 10%
### Phase 5 (Growth)
- Monthly active users growth > 20% MoM
- Free-to-paid conversion rate > 5%
- Customer acquisition cost < lifetime value
---
## Technical Debt & Infrastructure
### Ongoing Priorities
- [ ] Migration from legacy ConfigType to AppConfig
- [ ] Performance optimization (target 90fps in VR)
- [ ] Accessibility improvements (WCAG compliance)
- [ ] Testing coverage > 70%
- [ ] Documentation for contributors
- [ ] CI/CD pipeline enhancements
---
## Notes
**Dependencies:**
- Auth0 for user authentication
- PouchDB/CouchDB for data persistence
- BabylonJS 8.x for rendering
- Vite for build tooling
**Platform Support:**
- Desktop browsers (Chrome, Firefox, Edge)
- Meta Quest 2/3/Pro
- Future: PSVR2, Vision Pro (evaluate demand)
**Review Cadence:** Quarterly roadmap review and adjustment based on user feedback and metrics.
---
*Last Updated: 2025-11-19*

150
SHARING_PLAN.md Normal file
View File

@ -0,0 +1,150 @@
# Self-Hosted Diagram Sharing with Express-PouchDB
## Requirements (Confirmed)
- **Storage**: In-memory (ephemeral) - lost on server restart
- **Content**: Copy current diagram entities when creating share
- **Expiration**: No expiration - links work until server restart
- **Encryption**: None - keep it simple, anyone with link can access
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Express Server (port 3001) │
├─────────────────────────────────────────────────┤
│ /api/share/* → Share management API │
│ /pouchdb/* → express-pouchdb (sync) │
│ /share/:uuid → Client share route │
│ /api/* → Existing API routes │
│ /* → Vite static files │
└─────────────────────────────────────────────────┘
└── In-Memory PouchDB (per share UUID)
```
## Implementation Steps
### Phase 1: Server-Side Setup
#### 1.1 Add Dependencies
```bash
npm install express-pouchdb pouchdb-adapter-memory
```
#### 1.2 Create PouchDB Server Service
**New file: `server/services/pouchdbServer.js`**
- Initialize PouchDB with memory adapter
- Track active share databases in a Map
- Export `getShareDB(shareId)`, `shareExists(shareId)`, `createPouchDBMiddleware()`
#### 1.3 Create Share API
**New file: `server/api/share.js`**
- `POST /api/share/create` - Generate UUID, create in-memory DB, copy entities
- `GET /api/share/:id/exists` - Check if share exists
- `GET /api/share/stats` - Debug endpoint for active shares
#### 1.4 Update API Router
**Edit: `server/api/index.js`**
- Add `import shareRouter from "./share.js"`
- Mount at `router.use("/share", shareRouter)`
#### 1.5 Mount Express-PouchDB
**Edit: `server.js`**
- Import `createPouchDBMiddleware` from pouchdbServer.js
- Mount at `app.use("/pouchdb", createPouchDBMiddleware())`
---
### Phase 2: Client-Side Integration
#### 2.1 Update URL Parsing
**Edit: `src/util/functions/getPath.ts`**
- Add `getPathInfo()` function returning `{ dbName, isShare, shareId }`
- Detect `/share/:uuid` pattern
#### 2.2 Update PouchDB Persistence Manager
**Edit: `src/integration/database/pouchdbPersistenceManager.ts`**
In `initLocal()`:
- Call `getPathInfo()` to detect share URLs
- If share: use `share-{uuid}` as local DB name, call `beginShareSync()`
Add new method `beginShareSync(shareId)`:
- Check share exists via `/api/share/:id/exists`
- Connect to `${origin}/pouchdb/share-${shareId}`
- Set up presence with `share-${shareId}` as DB name
- Begin live sync (no encryption)
#### 2.3 Add React Route
**Edit: `src/react/webRouter.tsx`**
- Add route `{ path: "/share/:uuid", element: <VrExperience isShare={true} /> }`
- No ProtectedRoute wrapper (public access)
#### 2.4 Add Share Button Handler
**Edit: `src/react/pages/vrExperience.tsx`**
- Add `isShare` prop
- Add `handleShare()` function:
1. Get all entities from local PouchDB
2. POST to `/api/share/create` with entities
3. Copy resulting URL to clipboard
4. Show confirmation
---
### Phase 3: Presence Integration
The WebSocket presence system already routes by database name. Since shares use `share-{uuid}` as the database name, presence works automatically.
**Edit: `server/server.js`** (WebSocket server)
- Update `originIsAllowed()` to allow localhost for development
---
## Files to Modify
| File | Action | Purpose |
|------|--------|---------|
| `package.json` | Edit | Add express-pouchdb, pouchdb-adapter-memory |
| `server.js` | Edit | Mount /pouchdb middleware |
| `server/api/index.js` | Edit | Add share router |
| `server/services/pouchdbServer.js` | Create | PouchDB memory initialization |
| `server/api/share.js` | Create | Share API endpoints |
| `server/server.js` | Edit | Allow localhost origins |
| `src/util/functions/getPath.ts` | Edit | Add getPathInfo() |
| `src/integration/database/pouchdbPersistenceManager.ts` | Edit | Add share sync logic |
| `src/react/webRouter.tsx` | Edit | Add /share/:uuid route |
| `src/react/pages/vrExperience.tsx` | Edit | Add share button handler |
| `src/util/featureConfig.ts` | Edit | Enable shareCollaborate feature |
---
## User Flow
### Creating a Share
1. User has a diagram open at `/db/public/mydiagram`
2. Clicks "Share" button
3. Client fetches all entities from local PouchDB
4. POSTs to `/api/share/create` with entities
5. Server creates in-memory DB, copies entities, returns UUID
6. Client copies `https://server.com/share/{uuid}` to clipboard
7. User shares link with collaborators
### Joining a Share
1. User navigates to `https://server.com/share/{uuid}`
2. React Router renders VrExperience with `isShare=true`
3. PouchdbPersistenceManager detects share URL
4. Checks `/api/share/:uuid/exists` - returns true
5. Creates local PouchDB `share-{uuid}`
6. Connects to `/pouchdb/share-{uuid}` for sync
7. Entities replicate to local, render in scene
8. Presence WebSocket connects with `share-{uuid}` as room
---
## Future Authentication (Not Implemented Now)
Structure allows easy addition later:
- express-pouchdb middleware can be wrapped with auth middleware
- Share API can require JWT/session tokens
- Could add password-protected shares
- Could add read-only vs read-write permissions

179
SYNC_PLAN.md Normal file
View File

@ -0,0 +1,179 @@
# Future Sync Strategy: Keeping Local and Public Clones in Sync
## Current State (v1)
- Sharing creates a **ONE-TIME COPY** from local to public
- Copies diverge independently after sharing
- No automatic sync between local and public versions
- Local diagrams are browser-only (IndexedDB via PouchDB)
- Public diagrams sync with server via express-pouchdb
### URL Scheme
| Route | Sync | Access | Status |
|-------|------|--------|--------|
| `/db/local/:id` | None | Browser-only | Implemented |
| `/db/public/:id` | Yes | Anyone | Implemented |
| `/db/private/:id` | Yes | Authorized users | Route only (no auth) |
## Future Options
### Option 1: Manual Push/Pull (Recommended for v2)
Add explicit user-triggered sync between local and public copies.
**Features:**
- "Push to Public" button - sends local changes to public copy
- "Pull from Public" button - gets public changes into local
- Track `lastSyncedAt` timestamp
- Show indicator when copies have diverged
- Conflict resolution: Last write wins (simple) or user choice (advanced)
**Pros:**
- User stays in control
- Clear mental model
- Simple to implement incrementally
**Cons:**
- Manual effort required
- Risk of forgetting to sync
### Option 2: Automatic Background Sync
Continuous bidirectional sync between local and public copies.
**Features:**
- Real-time sync like Google Docs
- Works across devices
- Offline-first with automatic merge
**Pros:**
- Seamless experience
- Always up to date
**Cons:**
- Complex conflict resolution (may need CRDTs)
- Higher performance overhead
- Harder to reason about state
### Option 3: Fork/Branch Model
One-way relationship: local is "draft", public is "published".
**Features:**
- Push only (local → public)
- No pull mechanism
- Public is the "source of truth" once published
**Pros:**
- Clear mental model
- No merge conflicts
- Simple implementation
**Cons:**
- Cannot incorporate public changes back to local
- Multiple people can't collaborate on draft
## Recommended Implementation (v2)
Implement **Option 1 (Manual Push/Pull)** as it provides the best balance of user control and simplicity.
### Data Model Changes
Add to diagram directory entry:
```typescript
interface DiagramEntry {
_id: string;
name: string;
description: string;
storageType: 'local' | 'public' | 'private';
createdAt: string;
// New fields for sync tracking
publicCopyId?: string; // ID of the public clone (if shared)
lastPushedAt?: string; // When changes were last pushed to public
lastPulledAt?: string; // When public changes were last pulled
publicVersion?: number; // Version number of public copy at last sync
}
```
### API Endpoints
```typescript
// Push local changes to public
POST /api/sync/push
Body: { localDbName: string, publicDbName: string }
Response: { success: boolean, documentsUpdated: number }
// Pull public changes to local
POST /api/sync/pull
Body: { localDbName: string, publicDbName: string }
Response: { success: boolean, documentsUpdated: number }
// Check if copies have diverged
GET /api/sync/status?local={localDbName}&public={publicDbName}
Response: {
diverged: boolean,
localChanges: number,
publicChanges: number,
lastSyncedAt: string
}
```
### UI Components
1. **Sync Status Indicator**
- Shows in header when viewing a local diagram that has a public copy
- Green check: In sync
- Orange dot: Changes pending
- Red warning: Conflicts detected
2. **Push/Pull Buttons**
- In hamburger menu under "Share" section
- "Push to Public" - shows confirmation with change count
- "Pull from Public" - shows confirmation with change count
3. **Divergence Warning Badge**
- Shows on diagram card in Manage Diagrams modal
- Indicates when local and public have diverged
4. **Conflict Resolution Dialog**
- Shows when both local and public have changes to same entity
- Options: Keep Local, Keep Public, Keep Both (creates duplicate)
### Implementation Phases
**Phase 1: Tracking**
- Add `publicCopyId` when sharing local → public
- Track sharing relationship in directory
**Phase 2: Push**
- Implement push from local to public
- Overwrite public with local changes
- Update `lastPushedAt` timestamp
**Phase 3: Pull**
- Implement pull from public to local
- Merge public changes into local
- Update `lastPulledAt` timestamp
**Phase 4: Status**
- Implement divergence detection
- Add UI indicators
- Show sync status in Manage Diagrams
**Phase 5: Conflict Resolution**
- Detect entity-level conflicts
- Show resolution dialog
- Allow user to choose resolution strategy
## Migration Notes
Existing diagrams without `storageType` are treated as `public` for backwards compatibility. When such diagrams are loaded, the UI should work correctly but sync tracking features won't be available until the diagram metadata is updated.
## Security Considerations
- Push/pull operations should validate that the user has access to both databases
- Public databases remain world-readable/writable
- Private database sync will require authentication tokens
- Rate limiting should be applied to sync operations

297
VRCONFIGPLAN.md Normal file
View File

@ -0,0 +1,297 @@
# VR Configuration Panel Implementation Plan
## Overview
Create an immersive WebXR configuration panel that mirrors the 2D ConfigModal functionality using BabylonJS AdvancedDynamicTexture (ADT). The panel will allow users to adjust all application settings directly in VR.
## Recommended Approach: AdvancedDynamicTexture (ADT)
**Why ADT?**
- Most common approach for WebXR UI in BabylonJS
- Existing pattern in codebase (see `src/menus/configMenu.ts`)
- Good balance of simplicity and functionality
- Native support for text, buttons, sliders, and dropdowns
- Easy integration with existing Handle pattern
**Estimated Effort**: 150-200 lines of code, 4-8 hours implementation time
## File Structure
```
src/menus/
├── vrConfigPanel.ts (NEW - main implementation)
└── configMenu.ts (REFERENCE - existing VR config example)
src/diagram/
└── diagramMenuManager.ts (MODIFY - add toolbox button)
src/util/
└── appConfig.ts (USE - singleton for config management)
```
## Implementation Phases
### Phase 1: Core Panel Setup
- [ ] Create `src/menus/vrConfigPanel.ts` file
- [ ] Implement class structure following Handle pattern:
```typescript
export class VRConfigPanel {
private _scene: Scene;
private _handleMesh: Mesh;
private _advancedTexture: AdvancedDynamicTexture;
private _configObserver: Observer<AppConfigType>;
constructor(scene: Scene) {
// Initialize panel
}
public get handleMesh(): Mesh {
return this._handleMesh;
}
public show(): void {
this._handleMesh.setEnabled(true);
}
public hide(): void {
this._handleMesh.setEnabled(false);
}
public dispose(): void {
// Cleanup
}
}
```
- [ ] Create base mesh (plane) for panel backing
- [ ] Set up AdvancedDynamicTexture with appropriate resolution (1024x1024 or 2048x2048)
- [ ] Position panel at comfortable viewing distance (0.5-0.7m from camera)
- [ ] Make panel grabbable via Handle pattern
**Reference Files**:
- `src/menus/inputTextView.ts` - Handle pattern implementation
- `src/menus/configMenu.ts` - ADT usage example
### Phase 2: UI Layout Structure
- [ ] Create main container (StackPanel for vertical layout)
- [ ] Add title text at top ("Configuration")
- [ ] Create 5 section containers (one for each config group):
1. Location Snap
2. Rotation Snap
3. Fly Mode
4. Snap Turn
5. Label Rendering Mode
- [ ] Style containers with padding and spacing
- [ ] Add visual separators between sections
**ADT Components to Use**:
- `StackPanel` - Main vertical container
- `TextBlock` - Labels and section titles
- `Rectangle` - Containers and separators
**Reference**: `src/menus/configMenu.ts:44-89` for existing layout patterns
### Phase 3: Location Snap Section
- [ ] Add "Location Snap" label
- [ ] Create enable/disable toggle button
- Shows "Enabled" or "Disabled"
- Updates `appConfigInstance` on click
- [ ] Add RadioGroup for snap values:
- Options: 1cm (.01), 5cm (.05), 10cm (.1), 50cm (.5), 1m (1)
- Default: 10cm (.1)
- Disable when snap is off
- [ ] Wire up to `appConfigInstance.setGridSnap(value)`
- [ ] Subscribe to config changes to update UI
**ADT Components**:
- `Button` - Toggle switch
- `RadioButton` + `TextBlock` - Value selection
- Color coding: enabled (green/myColor), disabled (gray)
**Reference ConfigModal**: `src/react/pages/configModal.tsx:83-94`
### Phase 4: Rotation Snap Section
- [ ] Add "Rotation Snap" label
- [ ] Create enable/disable toggle button
- [ ] Add RadioGroup for rotation values:
- Options: 22.5°, 45°, 90°, 180°, 360°
- Default: 90°
- Disable when snap is off
- [ ] Wire up to `appConfigInstance.setRotateSnap(value)`
- [ ] Subscribe to config changes to update UI
**Reference ConfigModal**: `src/react/pages/configModal.tsx:96-108`
### Phase 5: Fly Mode Section
- [ ] Add "Fly Mode" label
- [ ] Create toggle button
- Shows "Fly Mode Enabled" or "Fly Mode Disabled"
- [ ] Wire up to `appConfigInstance.setFlyMode(value)`
- [ ] Subscribe to config changes to update UI
**Reference ConfigModal**: `src/react/pages/configModal.tsx:109-112`
### Phase 6: Snap Turn Section
- [ ] Add "Snap Turn" label
- [ ] Create enable/disable toggle button
- [ ] Add RadioGroup for snap turn angles:
- Options: 22.5°, 45°, 90°, 180°, 360°
- Default: 45°
- Disable when snap is off
- [ ] Wire up to `appConfigInstance.setTurnSnap(value)`
- [ ] Subscribe to config changes to update UI
**Reference ConfigModal**: `src/react/pages/configModal.tsx:113-125`
### Phase 7: Label Rendering Mode Section
- [ ] Add "Label Rendering Mode" label
- [ ] Create RadioGroup for rendering modes:
- Fixed
- Billboard (Always Face Camera)
- Dynamic (Coming Soon) - disabled
- Distance-based (Coming Soon) - disabled
- [ ] Wire up to `appConfigInstance.setLabelRenderingMode(value)`
- [ ] Subscribe to config changes to update UI
- [ ] Style disabled options with gray text
**Reference ConfigModal**: `src/react/pages/configModal.tsx:126-135`
### Phase 8: Integration with Toolbox
- [ ] Modify `src/diagram/diagramMenuManager.ts` to instantiate VRConfigPanel
- [ ] Add "Config" button to toolbox (similar to "Exit VR" button pattern)
- [ ] Wire up button click to show/hide panel
- [ ] Position panel relative to camera when shown (see `positionComponentsRelativeToCamera`)
- [ ] Add parent relationship to platform for movement tracking
**Reference**:
- `src/diagram/diagramMenuManager.ts:85-97` - Exit button creation
- `src/util/functions/groundMeshObserver.ts:127-222` - Component positioning
### Phase 9: Observable Integration
- [ ] Subscribe to `appConfigInstance.onConfigChangedObservable` in constructor
- [ ] Update all UI elements when config changes externally
- [ ] Ensure Observable cleanup in dispose() method
- [ ] Test config changes from both VR panel and 2D ConfigModal
**Pattern**:
```typescript
this._configObserver = appConfigInstance.onConfigChangedObservable.add((config) => {
// Update UI elements to reflect new config
this.updateLocationSnapUI(config.locationSnap);
this.updateRotationSnapUI(config.rotateSnap);
// ... etc
});
```
### Phase 10: Testing & Polish
- [ ] Test all toggle switches update config correctly
- [ ] Test all radio button selections update config correctly
- [ ] Verify config changes propagate to DiagramObjects (label mode, snap behavior)
- [ ] Test panel positioning in VR (comfortable viewing distance)
- [ ] Test panel grabbability via Handle
- [ ] Verify panel follows platform movement
- [ ] Test config persistence (localStorage)
- [ ] Test config synchronization between VR panel and 2D ConfigModal
- [ ] Add visual feedback for button clicks (color changes, animations)
- [ ] Ensure proper cleanup on panel disposal
- [ ] Test in both WebXR and desktop modes
## Code Patterns to Follow
### 1. Toggle Button Pattern
```typescript
const toggleButton = Button.CreateSimpleButton("toggle", "Enabled");
toggleButton.width = "200px";
toggleButton.height = "40px";
toggleButton.color = "white";
toggleButton.background = "green";
toggleButton.onPointerClickObservable.add(() => {
const newValue = !currentValue;
toggleButton.textBlock.text = newValue ? "Enabled" : "Disabled";
toggleButton.background = newValue ? "green" : "gray";
appConfigInstance.setSomeSetting(newValue);
});
```
### 2. RadioGroup Pattern
```typescript
const radioGroup = new SelectionPanel("snapValues");
const options = [
{ value: 0.01, label: "1cm" },
{ value: 0.1, label: "10cm" },
// ... more options
];
options.forEach(option => {
const radio = new RadioButton();
radio.width = "20px";
radio.height = "20px";
radio.isChecked = (option.value === currentValue);
radio.onIsCheckedChangedObservable.add((checked) => {
if (checked) {
appConfigInstance.setGridSnap(option.value);
}
});
// Add label next to radio button
});
```
### 3. Config Observer Pattern
```typescript
this._configObserver = appConfigInstance.onConfigChangedObservable.add((config) => {
this.updateUIFromConfig(config);
});
// In dispose():
if (this._configObserver) {
appConfigInstance.onConfigChangedObservable.remove(this._configObserver);
}
```
## Key Integration Points
### AppConfig Singleton
- Import: `import {appConfigInstance} from "../util/appConfig";`
- Read: `appConfigInstance.current.locationSnap`
- Write: `appConfigInstance.setGridSnap(0.1)`
- Subscribe: `appConfigInstance.onConfigChangedObservable.add(callback)`
### DiagramMenuManager
- Instantiate panel: `this._vrConfigPanel = new VRConfigPanel(this._scene);`
- Add button to toolbox: Follow exit button pattern in `setupExitButton()`
- Show panel: `this._vrConfigPanel.show();`
- Position panel: Follow pattern in `groundMeshObserver.ts:127-222`
### Handle Pattern
- Make panel grabbable by controllers
- Parent to platform for world movement
- Use `_handleMesh` as root for entire panel UI
## Reference Files
1. **src/menus/configMenu.ts** - Existing VR config implementation with ADT
2. **src/menus/inputTextView.ts** - Handle pattern and ADT setup
3. **src/react/pages/configModal.tsx** - UI structure and config sections
4. **src/util/appConfig.ts** - Config singleton and setter methods
5. **src/diagram/diagramMenuManager.ts** - Toolbox button creation
6. **src/util/functions/groundMeshObserver.ts** - Component positioning
## Success Criteria
- [ ] All 5 config sections implemented and functional
- [ ] Config changes in VR panel update appConfigInstance
- [ ] Config changes propagate to all DiagramObjects
- [ ] Panel is grabbable and repositionable
- [ ] Panel follows platform movement
- [ ] Config persists to localStorage
- [ ] Synchronized with 2D ConfigModal
- [ ] Comfortable viewing experience in VR
- [ ] No memory leaks (proper Observable cleanup)
## Notes
- Start hidden (only show when user clicks toolbox button)
- Position at ~0.5m in front of camera when opened
- Use Y-axis billboard mode to keep panel upright but allow rotation
- Consider adding "Close" button at bottom of panel
- Match color scheme with existing UI (myColor theme)
- Test with both left and right controller grabbing

138
docs/NAMING_CONVENTIONS.md Normal file
View File

@ -0,0 +1,138 @@
# Naming Conventions
## Tool and Material Naming
This document describes the naming conventions used for tools, materials, and related entities in the immersive WebXR application.
## Material Naming
Materials follow a consistent naming pattern based on their color:
**Format:** `material-{color}`
**Where:**
- `{color}` is the hex string representation of the material's color (e.g., `#ff0000` for red)
**Examples:**
- `material-#ff0000` - Red material
- `material-#00ff00` - Green material
- `material-#222222` - Dark gray material
**Implementation:**
```typescript
const material = new StandardMaterial("material-" + color.toHexString(), scene);
```
**Location:** Materials are created in:
- `src/toolbox/functions/buildColor.ts` - For toolbox color swatches
- `src/diagram/functions/buildMeshFromDiagramEntity.ts` - Fallback material creation via `buildMissingMaterial()`
## Tool Mesh Naming
Tool meshes use a compound naming pattern that includes both the tool type and color:
**Format:** `tool-{toolId}`
**Where:**
- `{toolId}` = `{toolType}-{color}`
- `{toolType}` is a value from the `ToolType` enum (e.g., `BOX`, `SPHERE`, `CYLINDER`, `CONE`, `PLANE`, `PERSON`)
- `{color}` is the hex string representation of the tool's color
**Examples:**
- `tool-BOX-#ff0000` - Red box tool
- `tool-SPHERE-#00ff00` - Green sphere tool
- `tool-CYLINDER-#0000ff` - Blue cylinder tool
- `tool-PLANE-#ffff00` - Yellow plane tool
**Implementation:**
```typescript
function toolId(tool: ToolType, color: Color3) {
return tool + "-" + color.toHexString();
}
const newItem = await buildMesh(tool, `tool-${id}`, colorParent.getScene());
// For example: `tool-BOX-#ff0000`
```
**Location:** Tool meshes are created in `src/toolbox/functions/buildTool.ts`
## Tool Colors
The application uses 16 predefined colors for the toolbox:
```typescript
const colors: string[] = [
"#222222", "#8b4513", "#006400", "#778899", // Row 1: Dark gray, Brown, Dark green, Light slate gray
"#4b0082", "#ff0000", "#ffa500", "#ffff00", // Row 2: Indigo, Red, Orange, Yellow
"#00ff00", "#00ffff", "#0000ff", "#ff00ff", // Row 3: Green, Cyan, Blue, Magenta
"#1e90ff", "#98fb98", "#ffe4b5", "#ff69b4" // Row 4: Dodger blue, Pale green, Moccasin, Hot pink
]
```
## Tool Types
Available tool types from the `ToolType` enum:
- `BOX` - Cube mesh
- `SPHERE` - Sphere mesh
- `CYLINDER` - Cylinder mesh
- `CONE` - Cone mesh
- `PLANE` - Flat plane mesh
- `PERSON` - Person/avatar mesh
## Material Color Access
When accessing material colors, use this priority order to handle both current and legacy materials:
```typescript
// For StandardMaterial
const stdMat = material as StandardMaterial;
const materialColor = stdMat.emissiveColor || stdMat.diffuseColor;
// Current rendering uses emissiveColor
// Legacy materials may have diffuseColor instead
```
## Rendering Modes
Materials can be rendered in three different modes, affecting how color properties are used:
### 1. Lightmap with Lighting
- Uses `diffuseColor` + `lightmapTexture`
- `disableLighting = false`
- Most expensive performance-wise
- Provides lighting illusion with actual lighting calculations
### 2. Unlit with Emissive Texture (Default)
- Uses `emissiveColor` + `emissiveTexture` (lightmap)
- `disableLighting = true`
- Best balance of visual quality and performance
- Provides lighting illusion without lighting calculations
### 3. Flat Emissive
- Uses only `emissiveColor`
- `disableLighting = true`
- Best performance
- No lighting illusion, flat shading
## Instance Naming
Instanced meshes (created from tool templates) follow this pattern:
**Format:** `tool-instance-{toolId}`
**Example:**
```typescript
const instance = new InstancedMesh("tool-instance-" + id, newItem);
// For example: `tool-instance-BOX-#ff0000`
```
These instances share materials with their source mesh and are used for visual feedback before creating actual diagram entities.
## Related Files
- `src/toolbox/functions/buildTool.ts` - Tool mesh creation and naming
- `src/toolbox/functions/buildColor.ts` - Material creation and color management
- `src/diagram/functions/buildMeshFromDiagramEntity.ts` - Diagram entity instantiation
- `src/toolbox/types/toolType.ts` - ToolType enum definition
- `src/util/lightmapGenerator.ts` - Lightmap texture generation and caching
- `src/util/renderingMode.ts` - Rendering mode enum and labels

View File

@ -3,49 +3,32 @@
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1" name="viewport"/>
<meta content="An immersive vr diagramming experience based using webxr version 0.0.8-14 (2024-07-03T13:09:05.707Z) 4fdcc9694d3614be538e425110d1ab50cd20b302"
<meta content="An immersive vr diagramming experience based using webxr version 0.0.8-14 (2024-12-29Z)"
name="description">
<meta content="width=device-width, initial-scale=1, height=device-height" name="viewport">
<link href="/styles.css" rel="stylesheet">
<link href="/assets/favicon-32x32.png" rel="icon" sizes="32x32" type="image/png">
<link href="/assets/favicon-16x16.png" rel="icon" sizes="16x16" type="image/png">
<link href="/assets/favicon-96x96.png" rel="icon" sizes="96x96" type="image/png">
<title>Deep Diagram</title>
<link as="script" href="/newRelic.js" rel="preload">
<script defer src="/newRelic.js"></script>
<script defer src="/src/webApp.ts" type="module"></script>
<script defer src="/src/vrApp.ts" type="module"></script>
<!--<link href="/styles.css" rel="stylesheet"> -->
<link href="/assets/dasfad/favicon-32x32.png" rel="icon" sizes="32x32" type="image/png">
<link href="/assets/dasfad/favicon-16x16.png" rel="icon" sizes="16x16" type="image/png">
<link href="/assets/dasfad/favicon-96x96.png" rel="icon" sizes="96x96" type="image/png">
<link as="fetch" href="/node_modules/.vite/deps/HavokPhysics.wasm" rel="preload">
<title>DASFAD</title>
<!-- <link as="script" href="/newRelic.js" rel="preload">
<script defer src="/newRelic.js"></script> -->
<link href="/manifest.webmanifest" rel="manifest"/>
<!--<script src='/niceware.js'></script>-->
<style>
#feed {
display: none;
}
#keyboardHelp {
display: none;
width: 665px;
height: 312px;
}
#keyboardHelp .button {
background-color: white;
width: 16px;
height: 16px;
display: inline-block;
text-align: center;
color: #000000;
}
#keyboardHelp div {
background: transparent;
}
</style>
</head>
<body>
<img id="loadingGrid" src="/assets/grid6.jpg"/>
<script>
if (typeof navigator.serviceWorker !== 'undefined') {
/* if (typeof navigator.serviceWorker !== 'undefined') {
if (localStorage.getItem('serviceWorkerVersion') !== '11') {
caches.keys().then(cacheNames => {
cacheNames.forEach(cacheName => {
@ -56,39 +39,21 @@
}
navigator.serviceWorker.register('/sw.js', {updateViaCache: 'none'});
}
</script>
<script>
/*
var SpeechRecognition = SpeechRecognition || webkitSpeechRecognition
var SpeechGrammarList = SpeechGrammarList || window.webkitSpeechGrammarList
var SpeechRecognitionEvent = SpeechRecognitionEvent || webkitSpeechRecognitionEvent
var recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.lang = 'en-US';
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.onresult = function(event) {
console.log(event.results[0][0].transcript);
}
recognition.onend = function() {
console.log("recognition ended");
recognition.start();
}
console.log("starting recognition");
recognition.start();
*/
</script>
<div class="webApp" id="webApp">
</div>
<script defer src="/src/webApp.ts" type="module"></script>
<!--<video id="feed" controls="" autoplay="" name="media"><source src="https://listen.broadcastify.com/1drb2xhywkg8nvz.mp3?nc=49099&amp;xan=xtf9912b41c" type="audio/mpeg"></video> -->
<!--
<div class="scene">
<canvas id="gameCanvas"></canvas>
</div>
-->
<!--<script defer src="/src/vrApp.ts" type="module"></script>-->
</body>
</html>

View File

@ -1,49 +0,0 @@
import {Handler, HandlerContext, HandlerEvent} from "@netlify/functions";
import axios from 'axios';
export const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
try {
switch (event.httpMethod) {
case 'POST':
const apiKey = event.headers['api-key'];
const query = event.body;
const response = await axios.post('https://api.newrelic.com/graphql',
query,
{headers: {'Api-Key': apiKey, 'Content-Type': 'application/json'}});
const data = await response.data;
return {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'https://cameras.immersiveidea.com',
'Access-Control-Allow-Credentials': 'true'
},
statusCode: 200,
body: JSON.stringify(data)
}
break;
case 'OPTIONS':
const headers = {
'Access-Control-Allow-Origin': 'https://cameras.immersiveidea.com',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers': 'content-type, api-key',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE'
};
return {
statusCode: 204,
headers
}
break;
default:
return {
statusCode: 405,
body: 'Method Not Allowed'
}
}
} catch (error) {
console.log(error);
return {
statusCode: 500,
body: JSON.stringify(error)
}
}
};

View File

@ -1,216 +0,0 @@
import axios from 'axios';
const baseurl = 'https://syncdb-service-d3f974de56ef.herokuapp.com/';
const auth = 'admin:stM8Lnm@Cuf-tWZHv';
const authToken = Buffer.from(auth).toString('base64');
type Params = {
username: string,
password: string,
db: string
}
async function checkDB(auth: string, db: string) {
try {
console.log('Checking for DB');
const exist = await axios.head(baseurl + db,
{headers: {'Authorization': 'Basic ' + auth}});
if (exist && exist.status == 200) {
console.log("DB Found");
return true;
}
} catch (err) {
console.log("DB not Found");
//console.log(err);
}
return false;
}
enum Access {
DENIED,
MISSING,
ALLOWED,
}
function getUserToken(params: Params) {
const userAuth = params.username + ':' + params.password;
return Buffer.from(userAuth).toString('base64');
}
async function checkIfDbExists(params: Params): Promise<Access> {
console.log("Checking if DB exists");
if (!params.username || !params.password || !params.db) {
throw new Error('No share key provided');
}
if (await checkDB(getUserToken(params), params.db)) {
return Access.ALLOWED;
}
if (await checkDB(authToken, params.db)) {
return Access.DENIED;
}
return Access.MISSING;
}
async function createDB(params: Params) {
console.log("Creating DB");
const response = await axios.put(
baseurl + params.db,
{},
{
headers: {
'Authorization': 'Basic ' + authToken,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
console.log(response.status);
console.log(response.data);
return response;
}
async function createUser(params: Params) {
try {
console.log("Checking for User");
const userResponse = await axios.head(
baseurl + '_users/org.couchdb.user:' + params.username,
{
headers: {
'Authorization': 'Basic ' + getUserToken(params),
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (userResponse.status == 200) {
console.log("User Found");
return userResponse;
}
} catch (err) {
console.log("User Missing");
}
console.log("Creating User");
const userResponse = await axios.put(
baseurl + '_users/org.couchdb.user:' + params.username,
{
_id: 'org.couchdb.user:' + params.username,
name: params.username,
password: params.password, roles: [], type: 'user'
},
{
headers: {
'Authorization': 'Basic ' + authToken,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
return userResponse;
}
async function authorizeUser(params: Params) {
console.log("Authorizing User");
return await axios.put(
baseurl + params.db + '/_security',
{admins: {names: [], roles: []}, members: {names: [params.username], roles: []}},
{
headers: {
'Authorization': 'Basic ' + authToken,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
}
export default async (req: Request): Promise<Response> => {
console.log(req.method);
try {
if (req.method == 'OPTIONS') {
const origin = req.headers.get('Origin');
const headers = req.headers.get('Access-Control-Request-Headers');
console.log(origin);
return new Response(
'OK',
{
headers: {
'Allow': 'POST',
'Max-Age': '30',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Origin': origin ? origin : 'https://cameras.immersiveidea.com',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers': headers ? headers : 'Content-Type'
},
status: 200
});
}
} catch (err) {
return new Response(
JSON.stringify(err),
{
headers: {
'Allow': 'POST',
'Max-Age': '30',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Origin': origin ? origin : 'https://cameras.immersiveidea.com',
'Access-Control-Allow-Credentials': 'true'
},
status: 500
});
}
try {
const params = JSON.parse(await req.text());
console.log(params);
const createUserResponse = await createUser(params);
console.log(createUserResponse.status);
if (createUserResponse.status != 201 && createUserResponse.status != 200) {
throw new Error('Could not create User');
}
const exists = await checkIfDbExists(params);
switch (exists) {
case Access.ALLOWED:
console.log('Allowed');
return new Response('OK', {status: 200});
case Access.DENIED:
console.log('Denied');
return new Response('Denied', {status: 401});
case Access.MISSING:
console.log('Creating Missing DB');
const createDbResponse = await createDB(params);
if (createDbResponse.status != 201) {
throw new Error('Could not create DB');
}
}
const authorizeUserResponse = await authorizeUser(params);
if (authorizeUserResponse.status != 200) {
throw new Error('could not authorize user');
}
const origin = req.headers.get('origin');
console.log(origin);
return new Response(
'OK',
{
headers: [
['Content-Type', 'application/json'],
['Access-Control-Allow-Origin', origin],
['Access-Control-Allow-Credentials', 'true']
],
status: 200
}
)
} catch (err) {
console.log(err);
const response = {err: err};
return new Response('Error',
{status: 500}
)
}
}

View File

@ -1,22 +0,0 @@
import {Handler, HandlerContext, HandlerEvent} from "@netlify/functions";
import axios from 'axios';
export const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
try {
const response = await axios.post('https://api.assemblyai.com/v2/realtime/token', // use account token to get a temp user token
{expires_in: 3600}, // can set a TTL timer in seconds.
{headers: {authorization: process.env.VOICE_TOKEN}});
const data = await response.data;
return {
headers: {'Content-Type': 'application/json'},
statusCode: 200,
body: JSON.stringify(data)
}
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify(error)
}
}
};

48
newrelic.cjs Normal file
View File

@ -0,0 +1,48 @@
'use strict'
// Load .env.local first (has the secrets), then .env as fallback
require('dotenv').config({ path: '.env.local' });
require('dotenv').config();
/**
* New Relic Node.js APM Configuration
*
* This file configures the New Relic agent for backend monitoring.
* Requires NEW_RELIC_LICENSE_KEY environment variable to be set.
*
* Distributed tracing is enabled to correlate with browser agent traces.
*/
exports.config = {
app_name: ['dasfad-backend'],
license_key: process.env.NEW_RELIC_LICENSE_KEY,
distributed_tracing: {
enabled: true
},
logging: {
level: 'info'
},
application_logging: {
enabled: true,
forwarding: {
enabled: true,
max_samples_stored: 10000
},
local_decorating: {
enabled: true
}
},
allow_all_headers: true,
attributes: {
exclude: [
'request.headers.cookie',
'request.headers.authorization',
'request.headers.proxyAuthorization',
'request.headers.setCookie*',
'request.headers.x*',
'response.headers.cookie',
'response.headers.authorization',
'response.headers.proxyAuthorization',
'response.headers.setCookie*',
'response.headers.x*'
]
}
}

6940
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,56 +1,76 @@
{
"name": "immersive",
"private": true,
"version": "0.0.8-16",
"version": "0.0.8-48",
"type": "module",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"dev": "vite",
"dev": "node -r newrelic server.js",
"test": "vitest",
"build": "node versionBump.js && vite build",
"preview": "vite preview",
"start": "NODE_ENV=production node -r newrelic server.js",
"start:api": "API_ONLY=true node -r newrelic server.js",
"socket": "node server/server.js",
"serve": "node server.js",
"serverBuild": "cd server && tsc",
"havok": "cp ./node_modules/@babylonjs/havok/lib/esm/HavokPhysics.wasm ./node_modules/.vite/deps"
},
"dependencies": {
"@babylonjs/core": "^7.21.5",
"@babylonjs/gui": "^7.21.5",
"@auth0/auth0-react": "^2.2.4",
"@babylonjs/core": "^8.16.2",
"@babylonjs/gui": "^8.16.2",
"@babylonjs/havok": "1.3.4",
"@babylonjs/inspector": "^7.21.5",
"@babylonjs/loaders": "^7.21.5",
"@babylonjs/materials": "^7.21.5",
"@babylonjs/serializers": "^7.21.5",
"@babylonjs/inspector": "^8.16.2",
"@babylonjs/loaders": "^8.16.2",
"@babylonjs/materials": "^8.16.2",
"@babylonjs/serializers": "^8.16.2",
"@emotion/react": "^11.13.0",
"@giphy/js-fetch-api": "^5.6.0",
"@giphy/react-components": "^9.6.0",
"@mantine/core": "^7.17.8",
"@mantine/form": "^7.17.8",
"@mantine/hooks": "^7.17.8",
"@maptiler/client": "1.8.1",
"@newrelic/browser-agent": "^1.306.0",
"@picovoice/cobra-web": "^2.0.3",
"@picovoice/eagle-web": "^1.0.0",
"@picovoice/web-voice-processor": "^4.0.9",
"@tabler/icons-react": "^3.14.0",
"@types/node": "^18.14.0",
"@types/react": "^18.2.72",
"@types/react-dom": "^18.2.22",
"axios": "^1.6.8",
"axios": "^1.10.0",
"canvas-hypertxt": "1.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"events": "^3.3.0",
"express": "^5.2.1",
"express-pouchdb": "^4.2.0",
"hash-wasm": "4.11.0",
"hls.js": "^1.1.4",
"js-crypto-aes": "1.0.6",
"leveldown": "^6.1.1",
"loglevel": "^1.9.1",
"meaningful-string": "^1.4.0",
"newrelic": "^13.9.1",
"peer-lite": "2.0.2",
"pouchdb": "^8.0.1",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-adapter-memory": "^9.0.0",
"pouchdb-find": "^8.0.1",
"query-string": "^8.1.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.1",
"recordrtc": "^5.6.0",
"rfc4648": "^1.5.3",
"round": "^2.0.1",
"uuid": "^9.0.1",
"js-crypto-aes": "1.0.6",
"events": "^3.3.0",
"hash-wasm": "4.11.0",
"uint8-to-b64": "^1.0.2",
"meaningful-string": "^1.4.0",
"websocket-ts": "^2.1.5",
"websocket": "^1.0.34"
"use-pouchdb": "^2.0.2",
"uuid": "^9.0.1",
"vite-express": "^0.21.1",
"websocket": "^1.0.34",
"websocket-ts": "^2.1.5"
},
"devDependencies": {
"@types/dom-to-image": "^2.6.7",

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="3200"
height="1355.480324331485" viewBox="0 0 3200 1355.480324331485">
<g transform="scale(10) translate(10, 10)">
<defs id="SvgjsDefs1385">
<linearGradient id="SvgjsLinearGradient1390">
<stop id="SvgjsStop1391" stop-color="#905e26" offset="0"></stop>
<stop id="SvgjsStop1392" stop-color="#f5ec9b" offset="0.5"></stop>
<stop id="SvgjsStop1393" stop-color="#905e26" offset="1"></stop>
</linearGradient>
<linearGradient id="SvgjsLinearGradient1394">
<stop id="SvgjsStop1395" stop-color="#905e26" offset="0"></stop>
<stop id="SvgjsStop1396" stop-color="#f5ec9b" offset="0.5"></stop>
<stop id="SvgjsStop1397" stop-color="#905e26" offset="1"></stop>
</linearGradient>
</defs>
<g id="SvgjsG1386" featureKey="aMgJeN-0"
transform="matrix(1.5610770874511997,0,0,1.5610770874511997,71.94613967240352,-53.841545411371435)"
fill="url(#SvgjsLinearGradient1390)">
<path xmlns="http://www.w3.org/2000/svg"
d="M29.399,57.112c0.615,0.308,1.077,0.846,1.077,1.383c0,1.039-1.038,1.961-1.999,1.462l-15.223-7.881 c-0.846-0.499-1.576-0.808-1.576-1.884c0-1.115,0.692-1.385,1.576-1.922l15.223-7.881c1.038-0.346,1.999,0.424,1.999,1.462 c0,0.575-0.461,1.114-1.077,1.423l-13.761,6.918L29.399,57.112z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M29.033,60.209c-0.208,0-0.413-0.052-0.608-0.152l-15.223-7.881c-0.086-0.05-0.165-0.095-0.242-0.141 c-0.748-0.431-1.395-0.804-1.395-1.843c0-1.057,0.594-1.406,1.346-1.849c0.093-0.054,0.187-0.11,0.284-0.169l15.229-7.885 c0.195-0.066,0.377-0.097,0.558-0.097c0.9,0,1.606,0.728,1.606,1.658c0,0.587-0.437,1.172-1.139,1.522l-13.562,6.818l13.562,6.818 c0.691,0.346,1.139,0.93,1.139,1.484C30.588,59.407,29.861,60.209,29.033,60.209z M28.982,40.419c-0.156,0-0.314,0.026-0.47,0.078 L13.306,48.37c-0.091,0.057-0.188,0.113-0.281,0.167c-0.743,0.439-1.234,0.728-1.234,1.655c0,0.91,0.537,1.219,1.281,1.648 c0.079,0.045,0.158,0.09,0.239,0.139l15.217,7.877c0.162,0.084,0.332,0.127,0.504,0.127c0.697,0,1.33-0.709,1.33-1.488 c0-0.465-0.407-0.98-1.014-1.283l-13.962-7.02l13.962-7.02c0.616-0.308,1.014-0.828,1.014-1.321 C30.363,41.048,29.756,40.419,28.982,40.419z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M46.385,64.416c-0.231,0.691-0.922,1.077-1.614,0.961c-0.769-0.153-1.269-0.885-1.154-1.692 c0-0.076,0.039-0.191,0.077-0.307l9.88-27.831c0.23-0.692,0.922-1.038,1.614-0.923c0.73,0.154,1.269,0.885,1.153,1.652 c0,0.078-0.039,0.193-0.077,0.27L46.385,64.416z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M45.016,65.511c-0.088,0-0.177-0.008-0.263-0.022c-0.837-0.167-1.371-0.949-1.247-1.819 c-0.001-0.076,0.038-0.193,0.079-0.318l9.883-27.842c0.207-0.618,0.778-1.02,1.457-1.02c0.094,0,0.188,0.009,0.281,0.023 c0.812,0.172,1.369,0.97,1.247,1.781c0.001,0.086-0.047,0.22-0.087,0.302l-9.875,27.856C46.281,65.084,45.688,65.511,45.016,65.511z M54.925,34.715c-0.58,0-1.068,0.34-1.244,0.867l-9.88,27.833c-0.035,0.104-0.07,0.213-0.07,0.27 c-0.108,0.767,0.349,1.439,1.062,1.582c0.071,0.012,0.147,0.018,0.223,0.018c0.575,0,1.083-0.363,1.263-0.904l9.881-27.872 c0.041-0.085,0.07-0.182,0.07-0.231c0.105-0.712-0.373-1.396-1.064-1.543C55.089,34.722,55.007,34.715,54.925,34.715z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M84.362,50.192L70.6,43.274c-0.614-0.309-1.075-0.848-1.075-1.423c0-1.038,1.037-1.962,1.998-1.462l15.223,7.881 c0.885,0.537,1.576,0.807,1.576,1.922c0,1.076-0.73,1.385-1.576,1.884l-15.223,7.881c-0.961,0.499-1.998-0.423-1.998-1.462 c0-0.537,0.461-1.075,1.075-1.383L84.362,50.192z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M70.967,60.209c-0.828,0-1.556-0.802-1.556-1.714c0-0.555,0.447-1.139,1.139-1.484l13.562-6.818L70.55,43.374 c-0.702-0.352-1.139-0.936-1.139-1.522c0-0.913,0.728-1.714,1.556-1.714c0.209,0,0.413,0.051,0.608,0.152l15.223,7.881 c0.104,0.062,0.198,0.119,0.29,0.173c0.753,0.442,1.347,0.792,1.347,1.849c0,1.039-0.646,1.412-1.395,1.843 c-0.078,0.046-0.157,0.091-0.237,0.138l-15.228,7.884C71.38,60.157,71.176,60.209,70.967,60.209z M70.967,40.363 c-0.696,0-1.33,0.709-1.33,1.488c0,0.493,0.397,1.013,1.014,1.321l13.962,7.02l-13.962,7.02c-0.606,0.305-1.014,0.82-1.014,1.283 c0,0.779,0.634,1.488,1.33,1.488c0.172,0,0.342-0.043,0.504-0.127l15.223-7.88c0.075-0.046,0.155-0.091,0.233-0.136 c0.744-0.43,1.282-0.738,1.282-1.648c0-0.928-0.491-1.216-1.235-1.655c-0.093-0.054-0.188-0.11-0.287-0.17l-15.216-7.876 C71.309,40.405,71.139,40.363,70.967,40.363z"></path>
</g>
<g id="SvgjsG1387" featureKey="8L6ael-0"
transform="matrix(3.168568052463937,0,0,3.168568052463937,-5.1330821487142195,52.17667742743372)"
fill="url(#SvgjsLinearGradient1394)">
<path d="M10.56 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z M32.208 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M45.535999999999994 12.42 q0.86 0.54 1.31 1.32 t0.45 1.74 q0 2.26 -1.72 3.46 q-1.16 0.84 -3.14 1.06 l-0.08 0 q-0.28 0 -0.46 -0.18 q-0.24 -0.22 -0.24 -0.52 l0 -1.44 q0 -0.26 0.18 -0.46 t0.44 -0.24 q0.94 -0.1 1.46 -0.44 q0.4 -0.24 0.54 -0.62 q0.08 -0.22 0.08 -0.56 q0 -0.22 -0.08 -0.38 t-0.28 -0.3 q-0.58 -0.4 -1.5 -0.68 l-0.32 -0.1 q-1 -0.28 -1.84 -0.46 q-0.18 -0.04 -0.58 -0.14 l-0.22 -0.06 q-0.72 -0.18 -1.52 -0.5 q-1.18 -0.5 -1.94 -1.26 q-0.88 -0.88 -0.88 -2.32 q0 -1.98 1.52 -3.22 q1.04 -0.88 2.92 -1.12 q0.32 -0.04 0.55 0.18 t0.23 0.52 l0 1.44 q0 0.26 -0.16 0.46 t-0.41 0.23 t-0.51 0.11 q-0.58 0.22 -0.82 0.43 t-0.34 0.43 q-0.1 0.34 -0.1 0.64 q0 0.16 0.18 0.34 q0.28 0.28 0.82 0.5 q0.24 0.1 0.84 0.3 l2.5 0.62 l0.12 0.04 q0.94 0.26 1.36 0.4 q0.94 0.32 1.64 0.78 z M42.196 7.9 q-0.24 -0.06 -0.39 -0.25 t-0.15 -0.45 l0 -1.46 q0 -0.32 0.26 -0.54 q0.1 -0.1 0.26 -0.13 t0.3 -0.01 q1.76 0.28 2.86 1.2 q1.5 1.24 1.6 3.16 q0.02 0.28 -0.19 0.51 t-0.51 0.23 l-1.56 0 q-0.26 0 -0.46 -0.18 t-0.22 -0.44 q-0.08 -0.52 -0.34 -0.86 q-0.42 -0.5 -1.3 -0.76 q-0.06 0 -0.08 -0.02 l-0.08 0 z M39.855999999999995 17.08 q0.24 0.04 0.4 0.24 t0.16 0.44 l0 1.48 q0 0.32 -0.24 0.54 q-0.2 0.16 -0.46 0.16 l-0.1 0 q-1.86 -0.28 -3.06 -1.22 q-1.56 -1.24 -1.76 -3.46 q-0.04 -0.32 0.18 -0.55 t0.52 -0.23 l1.58 0 q0.28 0 0.48 0.19 t0.22 0.47 q0.06 1 0.98 1.54 q0.42 0.24 1.1 0.4 z M60.88399999999999 11.12 q0.3 0 0.51 0.21 t0.21 0.51 l0 1.48 q0 0.28 -0.21 0.49 t-0.51 0.21 l-6.58 0 l0 5.12 q0 0.28 -0.2 0.49 t-0.5 0.21 l-1.52 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -7.3 q0 -0.3 0.21 -0.51 t0.49 -0.21 l8.8 0 z M61.78399999999999 5 q0.28 0 0.49 0.21 t0.21 0.49 l0 1.46 q0 0.3 -0.21 0.51 t-0.49 0.21 l-9.7 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.46 q0 -0.28 0.21 -0.49 t0.49 -0.21 l9.7 0 z M79.512 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M92 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,912 @@
%!PS-Adobe-3.0 EPSF-3.0
%Produced by poppler pdftops version: 22.05.0 (http://poppler.freedesktop.org)
%%Creator: Chromium
%%LanguageLevel: 3
%%DocumentSuppliedResources: (atend)
%%BoundingBox: 0 0 2400 1018
%%HiResBoundingBox: 0 0 2400 1017.12
%%DocumentSuppliedResources: (atend)
%%EndComments
%%BeginProlog
%%BeginResource: procset xpdf 3.00 0
%%Copyright: Copyright 1996-2011, 2022 Glyph & Cog, LLC
/xpdf 75 dict def xpdf begin
% PDF special state
/pdfDictSize 15 def
/pdfSetup {
/setpagedevice where {
pop 2 dict begin
/Policies 1 dict dup begin /PageSize 6 def end def
{ /Duplex true def } if
currentdict end setpagedevice
} {
pop
} ifelse
} def
/pdfSetupPaper {
% Change paper size, but only if different from previous paper size otherwise
% duplex fails. PLRM specifies a tolerance of 5 pts when matching paper size
% so we use the same when checking if the size changes.
/setpagedevice where {
pop currentpagedevice
/PageSize known {
2 copy
currentpagedevice /PageSize get aload pop
exch 4 1 roll
sub abs 5 gt
3 1 roll
sub abs 5 gt
or
} {
true
} ifelse
{
2 array astore
2 dict begin
/PageSize exch def
/ImagingBBox null def
currentdict end
setpagedevice
} {
pop pop
} ifelse
} {
pop
} ifelse
} def
/pdfStartPage {
pdfDictSize dict begin
/pdfFillCS [] def
/pdfFillXform {} def
/pdfStrokeCS [] def
/pdfStrokeXform {} def
/pdfFill [0] def
/pdfStroke [0] def
/pdfFillOP false def
/pdfStrokeOP false def
/pdfOPM false def
/pdfLastFill false def
/pdfLastStroke false def
/pdfTextMat [1 0 0 1 0 0] def
/pdfFontSize 0 def
/pdfCharSpacing 0 def
/pdfTextRender 0 def
/pdfPatternCS false def
/pdfTextRise 0 def
/pdfWordSpacing 0 def
/pdfHorizScaling 1 def
/pdfTextClipPath [] def
} def
/pdfEndPage { end } def
% PDF color state
/opm { dup /pdfOPM exch def
/setoverprintmode where{pop setoverprintmode}{pop}ifelse } def
/cs { /pdfFillXform exch def dup /pdfFillCS exch def
setcolorspace } def
/CS { /pdfStrokeXform exch def dup /pdfStrokeCS exch def
setcolorspace } def
/sc { pdfLastFill not { pdfFillCS setcolorspace } if
dup /pdfFill exch def aload pop pdfFillXform setcolor
/pdfLastFill true def /pdfLastStroke false def } def
/SC { pdfLastStroke not { pdfStrokeCS setcolorspace } if
dup /pdfStroke exch def aload pop pdfStrokeXform setcolor
/pdfLastStroke true def /pdfLastFill false def } def
/op { /pdfFillOP exch def
pdfLastFill { pdfFillOP setoverprint } if } def
/OP { /pdfStrokeOP exch def
pdfLastStroke { pdfStrokeOP setoverprint } if } def
/fCol {
pdfLastFill not {
pdfFillCS setcolorspace
pdfFill aload pop pdfFillXform setcolor
pdfFillOP setoverprint
/pdfLastFill true def /pdfLastStroke false def
} if
} def
/sCol {
pdfLastStroke not {
pdfStrokeCS setcolorspace
pdfStroke aload pop pdfStrokeXform setcolor
pdfStrokeOP setoverprint
/pdfLastStroke true def /pdfLastFill false def
} if
} def
% build a font
/pdfMakeFont {
4 3 roll findfont
4 2 roll matrix scale makefont
dup length dict begin
{ 1 index /FID ne { def } { pop pop } ifelse } forall
/Encoding exch def
currentdict
end
definefont pop
} def
/pdfMakeFont16 {
exch findfont
dup length dict begin
{ 1 index /FID ne { def } { pop pop } ifelse } forall
/WMode exch def
currentdict
end
definefont pop
} def
/pdfMakeFont16L3 {
1 index /CIDFont resourcestatus {
pop pop 1 index /CIDFont findresource /CIDFontType known
} {
false
} ifelse
{
0 eq { /Identity-H } { /Identity-V } ifelse
exch 1 array astore composefont pop
} {
pdfMakeFont16
} ifelse
} def
% graphics state operators
/q { gsave pdfDictSize dict begin } def
/Q {
end grestore
/pdfLastFill where {
pop
pdfLastFill {
pdfFillOP setoverprint
} {
pdfStrokeOP setoverprint
} ifelse
} if
/pdfOPM where {
pop
pdfOPM /setoverprintmode where{pop setoverprintmode}{pop}ifelse
} if
} def
/cm { concat } def
/d { setdash } def
/i { setflat } def
/j { setlinejoin } def
/J { setlinecap } def
/M { setmiterlimit } def
/w { setlinewidth } def
% path segment operators
/m { moveto } def
/l { lineto } def
/c { curveto } def
/re { 4 2 roll moveto 1 index 0 rlineto 0 exch rlineto
neg 0 rlineto closepath } def
/h { closepath } def
% path painting operators
/S { sCol stroke } def
/Sf { fCol stroke } def
/f { fCol fill } def
/f* { fCol eofill } def
% clipping operators
/W { clip newpath } def
/W* { eoclip newpath } def
/Ws { strokepath clip newpath } def
% text state operators
/Tc { /pdfCharSpacing exch def } def
/Tf { dup /pdfFontSize exch def
dup pdfHorizScaling mul exch matrix scale
pdfTextMat matrix concatmatrix dup 4 0 put dup 5 0 put
exch findfont exch makefont setfont } def
/Tr { /pdfTextRender exch def } def
/Tp { /pdfPatternCS exch def } def
/Ts { /pdfTextRise exch def } def
/Tw { /pdfWordSpacing exch def } def
/Tz { /pdfHorizScaling exch def } def
% text positioning operators
/Td { pdfTextMat transform moveto } def
/Tm { /pdfTextMat exch def } def
% text string operators
/xyshow where {
pop
/xyshow2 {
dup length array
0 2 2 index length 1 sub {
2 index 1 index 2 copy get 3 1 roll 1 add get
pdfTextMat dtransform
4 2 roll 2 copy 6 5 roll put 1 add 3 1 roll dup 4 2 roll put
} for
exch pop
xyshow
} def
}{
/xyshow2 {
currentfont /FontType get 0 eq {
0 2 3 index length 1 sub {
currentpoint 4 index 3 index 2 getinterval show moveto
2 copy get 2 index 3 2 roll 1 add get
pdfTextMat dtransform rmoveto
} for
} {
0 1 3 index length 1 sub {
currentpoint 4 index 3 index 1 getinterval show moveto
2 copy 2 mul get 2 index 3 2 roll 2 mul 1 add get
pdfTextMat dtransform rmoveto
} for
} ifelse
pop pop
} def
} ifelse
/cshow where {
pop
/xycp {
0 3 2 roll
{
pop pop currentpoint 3 2 roll
1 string dup 0 4 3 roll put false charpath moveto
2 copy get 2 index 2 index 1 add get
pdfTextMat dtransform rmoveto
2 add
} exch cshow
pop pop
} def
}{
/xycp {
currentfont /FontType get 0 eq {
0 2 3 index length 1 sub {
currentpoint 4 index 3 index 2 getinterval false charpath moveto
2 copy get 2 index 3 2 roll 1 add get
pdfTextMat dtransform rmoveto
} for
} {
0 1 3 index length 1 sub {
currentpoint 4 index 3 index 1 getinterval false charpath moveto
2 copy 2 mul get 2 index 3 2 roll 2 mul 1 add get
pdfTextMat dtransform rmoveto
} for
} ifelse
pop pop
} def
} ifelse
/Tj {
fCol
0 pdfTextRise pdfTextMat dtransform rmoveto
currentpoint 4 2 roll
pdfTextRender 1 and 0 eq {
2 copy xyshow2
} if
pdfTextRender 3 and dup 1 eq exch 2 eq or {
3 index 3 index moveto
2 copy
currentfont /FontType get 3 eq { fCol } { sCol } ifelse
xycp currentpoint stroke moveto
} if
pdfTextRender 4 and 0 ne {
4 2 roll moveto xycp
/pdfTextClipPath [ pdfTextClipPath aload pop
{/moveto cvx}
{/lineto cvx}
{/curveto cvx}
{/closepath cvx}
pathforall ] def
currentpoint newpath moveto
} {
pop pop pop pop
} ifelse
0 pdfTextRise neg pdfTextMat dtransform rmoveto
} def
/TJm { 0.001 mul pdfFontSize mul pdfHorizScaling mul neg 0
pdfTextMat dtransform rmoveto } def
/TJmV { 0.001 mul pdfFontSize mul neg 0 exch
pdfTextMat dtransform rmoveto } def
/Tclip { pdfTextClipPath cvx exec clip newpath
/pdfTextClipPath [] def } def
/Tclip* { pdfTextClipPath cvx exec eoclip newpath
/pdfTextClipPath [] def } def
% Level 2/3 image operators
/pdfImBuf 100 string def
/pdfImStr {
2 copy exch length lt {
2 copy get exch 1 add exch
} {
()
} ifelse
} def
/skipEOD {
{ currentfile pdfImBuf readline
not { pop exit } if
(%-EOD-) eq { exit } if } loop
} def
/pdfIm { image skipEOD } def
/pdfMask {
/ReusableStreamDecode filter
skipEOD
/maskStream exch def
} def
/pdfMaskEnd { maskStream closefile } def
/pdfMaskInit {
/maskArray exch def
/maskIdx 0 def
} def
/pdfMaskSrc {
maskIdx maskArray length lt {
maskArray maskIdx get
/maskIdx maskIdx 1 add def
} {
()
} ifelse
} def
/pdfImM { fCol imagemask skipEOD } def
/pr { 2 index 2 index 3 2 roll putinterval 4 add } def
/pdfImClip {
gsave
0 2 4 index length 1 sub {
dup 4 index exch 2 copy
get 5 index div put
1 add 3 index exch 2 copy
get 3 index div put
} for
pop pop rectclip
} def
/pdfImClipEnd { grestore } def
% shading operators
/colordelta {
false 0 1 3 index length 1 sub {
dup 4 index exch get 3 index 3 2 roll get sub abs 0.004 gt {
pop true
} if
} for
exch pop exch pop
} def
/funcCol { func n array astore } def
/funcSH {
dup 0 eq {
true
} {
dup 6 eq {
false
} {
4 index 4 index funcCol dup
6 index 4 index funcCol dup
3 1 roll colordelta 3 1 roll
5 index 5 index funcCol dup
3 1 roll colordelta 3 1 roll
6 index 8 index funcCol dup
3 1 roll colordelta 3 1 roll
colordelta or or or
} ifelse
} ifelse
{
1 add
4 index 3 index add 0.5 mul exch 4 index 3 index add 0.5 mul exch
6 index 6 index 4 index 4 index 4 index funcSH
2 index 6 index 6 index 4 index 4 index funcSH
6 index 2 index 4 index 6 index 4 index funcSH
5 3 roll 3 2 roll funcSH pop pop
} {
pop 3 index 2 index add 0.5 mul 3 index 2 index add 0.5 mul
funcCol sc
dup 4 index exch mat transform m
3 index 3 index mat transform l
1 index 3 index mat transform l
mat transform l pop pop h f*
} ifelse
} def
/axialCol {
dup 0 lt {
pop t0
} {
dup 1 gt {
pop t1
} {
dt mul t0 add
} ifelse
} ifelse
func n array astore
} def
/axialSH {
dup 0 eq {
true
} {
dup 8 eq {
false
} {
2 index axialCol 2 index axialCol colordelta
} ifelse
} ifelse
{
1 add 3 1 roll 2 copy add 0.5 mul
dup 4 3 roll exch 4 index axialSH
exch 3 2 roll axialSH
} {
pop 2 copy add 0.5 mul
axialCol sc
exch dup dx mul x0 add exch dy mul y0 add
3 2 roll dup dx mul x0 add exch dy mul y0 add
dx abs dy abs ge {
2 copy yMin sub dy mul dx div add yMin m
yMax sub dy mul dx div add yMax l
2 copy yMax sub dy mul dx div add yMax l
yMin sub dy mul dx div add yMin l
h f*
} {
exch 2 copy xMin sub dx mul dy div add xMin exch m
xMax sub dx mul dy div add xMax exch l
exch 2 copy xMax sub dx mul dy div add xMax exch l
xMin sub dx mul dy div add xMin exch l
h f*
} ifelse
} ifelse
} def
/radialCol {
dup t0 lt {
pop t0
} {
dup t1 gt {
pop t1
} if
} ifelse
func n array astore
} def
/radialSH {
dup 0 eq {
true
} {
dup 8 eq {
false
} {
2 index dt mul t0 add radialCol
2 index dt mul t0 add radialCol colordelta
} ifelse
} ifelse
{
1 add 3 1 roll 2 copy add 0.5 mul
dup 4 3 roll exch 4 index radialSH
exch 3 2 roll radialSH
} {
pop 2 copy add 0.5 mul dt mul t0 add
radialCol sc
encl {
exch dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
0 360 arc h
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
360 0 arcn h f
} {
2 copy
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
a1 a2 arcn
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
a2 a1 arcn h
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
a1 a2 arc
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
a2 a1 arc h f
} ifelse
} ifelse
} def
end
%%EndResource
/CIDInit /ProcSet findresource begin
10 dict begin
begincmap
/CMapType 1 def
/CMapName /Identity-H def
/CIDSystemInfo 3 dict dup begin
/Registry (Adobe) def
/Ordering (Identity) def
/Supplement 0 def
end def
1 begincodespacerange
<0000> <ffff>
endcodespacerange
0 usefont
1 begincidrange
<0000> <ffff> 0
endcidrange
endcmap
currentdict CMapName exch /CMap defineresource pop
end
10 dict begin
begincmap
/CMapType 1 def
/CMapName /Identity-V def
/CIDSystemInfo 3 dict dup begin
/Registry (Adobe) def
/Ordering (Identity) def
/Supplement 0 def
end def
/WMode 1 def
1 begincodespacerange
<0000> <ffff>
endcodespacerange
0 usefont
1 begincidrange
<0000> <ffff> 0
endcidrange
endcmap
currentdict CMapName exch /CMap defineresource pop
end
end
%%EndProlog
%%BeginSetup
xpdf begin
%%EndSetup
pdfStartPage
%%EndPageSetup
[] 0 d
1 i
0 j
0 J
10 M
1 w
/DeviceGray {} cs
[0] sc
/DeviceGray {} CS
[0] SC
false op
false OP
{} settransfer
0 0 2400 1017.12 re
W
q
[0.24 0 0 -0.24 0 1017.12] cm
q
0 0 10000 4234.375 re
W*
q
[48.783241 0 0 48.766369 2560.8376 -1369.56274] cm
29.399 57.112 m
30.014 57.419998 30.476 57.958 30.476 58.494999 c
30.476 59.534 29.438 60.455997 28.476999 59.957001 c
13.254 52.076 l
12.408 51.577 11.678 51.268002 11.678 50.192001 c
11.678 49.077 12.37 48.807003 13.254 48.27 c
28.476999 40.389 l
29.514999 40.042999 30.476 40.813 30.476 41.851002 c
30.476 42.426003 30.014999 42.965 29.399 43.274002 c
15.638 50.192001 l
29.399 57.112 l
h
f
29.033001 60.209 m
28.825001 60.209 28.620001 60.157001 28.425001 60.056999 c
13.202002 52.175999 l
13.116001 52.125999 13.037002 52.080997 12.960002 52.035 c
12.212002 51.604 11.565002 51.230999 11.565002 50.192001 c
11.565002 49.135002 12.159002 48.786003 12.911002 48.343002 c
13.004003 48.289001 13.098002 48.233002 13.195003 48.174004 c
28.424004 40.289001 l
28.619003 40.223 28.801004 40.192001 28.982004 40.192001 c
29.882004 40.192001 30.588005 40.920002 30.588005 41.850002 c
30.588005 42.437004 30.151005 43.022003 29.449005 43.372002 c
15.887005 50.190002 l
29.449005 57.008003 l
30.140005 57.354004 30.588005 57.938004 30.588005 58.492004 c
30.587999 59.407001 29.861 60.209 29.033001 60.209 c
h
28.982 40.418999 m
28.826 40.418999 28.668001 40.445 28.512001 40.496998 c
13.306 48.369999 l
13.215 48.426998 13.118 48.482998 13.025 48.536999 c
12.282 48.975998 11.790999 49.264999 11.790999 50.191998 c
11.790999 51.101997 12.327999 51.410999 13.072 51.839996 c
13.151 51.884995 13.23 51.929996 13.311 51.978996 c
28.528 59.855995 l
28.690001 59.939995 28.860001 59.982994 29.032 59.982994 c
29.729 59.982994 30.362 59.273994 30.362 58.494995 c
30.362 58.029995 29.955 57.514996 29.348 57.211994 c
15.386 50.191994 l
29.348 43.171993 l
29.963999 42.863995 30.362 42.343994 30.362 41.850994 c
30.363001 41.048 29.756001 40.418999 28.982 40.418999 c
h
f
46.384998 64.416 m
46.153999 65.107002 45.462997 65.493004 44.771 65.376999 c
44.001999 65.223999 43.501999 64.491997 43.617001 63.684998 c
43.617001 63.608997 43.656002 63.493996 43.694 63.377998 c
53.574001 35.546997 l
53.804001 34.854996 54.496002 34.508999 55.188 34.623997 c
55.917999 34.777996 56.457001 35.508995 56.341 36.275997 c
56.341 36.353996 56.301998 36.468998 56.264 36.545998 c
46.384998 64.416 l
h
f
45.015999 65.511002 m
44.927998 65.511002 44.839001 65.502998 44.752998 65.488998 c
43.915997 65.321999 43.382 64.540001 43.505997 63.669998 c
43.504997 63.593998 43.543995 63.476997 43.584995 63.351997 c
53.467995 35.509998 l
53.674995 34.891998 54.245995 34.489998 54.924995 34.489998 c
55.018997 34.489998 55.112995 34.498997 55.205994 34.512997 c
56.017994 34.684998 56.574993 35.482998 56.452995 36.293995 c
56.453995 36.379993 56.405994 36.513996 56.365993 36.595993 c
46.490993 64.451996 l
46.280998 65.084 45.688 65.511002 45.015999 65.511002 c
h
54.924999 34.715 m
54.344997 34.715 53.856998 35.055 53.681 35.582001 c
43.800999 63.415001 l
43.765999 63.519001 43.730999 63.628002 43.730999 63.685001 c
43.622997 64.452003 44.079998 65.124001 44.792999 65.266998 c
44.863998 65.278999 44.939999 65.284996 45.015999 65.284996 c
45.591 65.284996 46.098999 64.921997 46.278999 64.380997 c
56.16 36.508995 l
56.201 36.423996 56.23 36.326996 56.23 36.277996 c
56.334999 35.565994 55.856998 34.881996 55.166 34.734997 c
55.089001 34.722 55.007 34.715 54.924999 34.715 c
h
f
84.362 50.192001 m
70.599998 43.273998 l
69.986 42.964996 69.525002 42.425999 69.525002 41.850998 c
69.525002 40.813 70.562004 39.888996 71.523003 40.388996 c
86.746002 48.269997 l
87.631004 48.806995 88.321999 49.076996 88.321999 50.191998 c
88.321999 51.267998 87.591995 51.576996 86.746002 52.075996 c
71.523003 59.956997 l
70.562004 60.455997 69.525002 59.533997 69.525002 58.494995 c
69.525002 57.957996 69.986 57.419994 70.599998 57.111996 c
84.362 50.192001 l
h
f
70.967003 60.209 m
70.139 60.209 69.411003 59.407001 69.411003 58.494999 c
69.411003 57.939999 69.858002 57.355999 70.550003 57.010998 c
84.112 50.192997 l
70.550003 43.374001 l
69.848 43.021999 69.411003 42.438 69.411003 41.852001 c
69.411003 40.939003 70.139 40.138 70.967003 40.138 c
71.176003 40.138 71.380005 40.188999 71.575005 40.290001 c
86.798004 48.171001 l
86.902 48.233002 86.996002 48.290001 87.088005 48.344002 c
87.841003 48.786003 88.435005 49.136002 88.435005 50.193001 c
88.435005 51.232002 87.789001 51.605 87.040009 52.035999 c
86.962006 52.082001 86.883011 52.126999 86.803009 52.174 c
71.575012 60.057999 l
71.379997 60.157001 71.176003 60.209 70.967003 60.209 c
h
70.967003 40.362999 m
70.271004 40.362999 69.637001 41.071999 69.637001 41.850998 c
69.637001 42.343998 70.034004 42.863998 70.651001 43.171997 c
84.612999 50.191998 l
70.651001 57.211998 l
70.044998 57.516998 69.637001 58.031998 69.637001 58.494999 c
69.637001 59.273998 70.271004 59.982998 70.967003 59.982998 c
71.139 59.982998 71.309006 59.939999 71.471001 59.855999 c
86.694 51.975998 l
86.768997 51.929996 86.848999 51.884998 86.927002 51.839996 c
87.671005 51.409996 88.209 51.101997 88.209 50.191998 c
88.209 49.263996 87.718002 48.975998 86.973999 48.536999 c
86.880997 48.482998 86.785995 48.426998 86.686996 48.367001 c
71.470993 40.491001 l
71.308998 40.404999 71.139 40.362999 70.967003 40.362999 c
h
f
Q
q
[99.016907 0 0 98.982658 152.13266 1942.3326] cm
10.56 5.52 m
11.373334 5.826667 12.106668 6.32 12.76 7 c
14.160001 8.413333 14.860001 10.216666 14.860001 12.41 c
14.860001 14.603334 14.160001 16.413334 12.76 17.84 c
12.106668 18.52 11.373334 19.013334 10.56 19.32 c
9.72 19.666666 8.860001 19.84 7.980001 19.84 c
2.320001 19.84 l
2.133334 19.84 1.970001 19.77 1.830001 19.630001 c
1.690001 19.490002 1.620001 19.326668 1.620001 19.140001 c
1.620001 17.700001 l
1.620001 17.500002 1.690001 17.330002 1.830001 17.190001 c
1.970001 17.049999 2.133334 16.98 2.320001 16.980001 c
7.920001 16.980001 l
9.106668 16.980001 10.070001 16.546669 10.81 15.680001 c
11.55 14.813335 11.92 13.726667 11.92 12.420001 c
11.92 11.113335 11.55 10.026668 10.81 9.160001 c
10.070001 8.293334 9.106668 7.860001 7.920001 7.860001 c
2.320001 7.860001 l
2.133334 7.860001 1.970001 7.79 1.830001 7.650001 c
1.690001 7.510001 1.620001 7.340001 1.620001 7.14 c
1.620001 5.7 l
1.620001 5.513333 1.690001 5.35 1.830001 5.21 c
1.970001 5.07 2.133334 5 2.320001 5 c
7.980001 5 l
8.860001 5 9.72 5.173333 10.56 5.52 c
h
32.208 18.860001 m
32.301334 19.073334 32.278 19.290001 32.138 19.51 c
31.998001 19.73 31.808001 19.84 31.568001 19.84 c
18.248001 19.84 l
18.128 19.84 18.014668 19.809999 17.908001 19.75 c
17.801334 19.690001 17.721334 19.613333 17.668001 19.52 c
17.521336 19.306667 17.501335 19.086668 17.608002 18.860001 c
18.228003 17.400002 l
18.281336 17.266668 18.368002 17.160002 18.488003 17.080002 c
18.608004 17.000002 18.734669 16.960003 18.868002 16.960001 c
28.188002 16.960001 l
24.908001 9.120001 l
22.228001 15.520001 l
22.174667 15.653334 22.091333 15.756667 21.978001 15.830001 c
21.864668 15.903335 21.734667 15.940002 21.588001 15.940001 c
19.908001 15.940001 l
19.654669 15.940001 19.454668 15.833334 19.308001 15.620001 c
19.254667 15.526668 19.221334 15.420001 19.208 15.300001 c
19.194666 15.180001 19.208 15.066669 19.248001 14.960001 c
23.308001 5.440001 l
23.361334 5.306667 23.444668 5.200001 23.558001 5.12 c
23.671333 5.04 23.801334 5 23.948 5.000001 c
25.868 5.000001 l
26.014666 5.000001 26.144667 5.04 26.257999 5.12 c
26.371332 5.2 26.454666 5.306667 26.507999 5.440001 c
32.208 18.860001 l
h
45.535999 12.42 m
46.109333 12.78 46.546001 13.22 46.846001 13.74 c
47.146 14.259999 47.296001 14.839999 47.296001 15.48 c
47.296001 16.986666 46.722668 18.139999 45.576 18.939999 c
44.802666 19.499998 43.756001 19.853333 42.436001 19.999998 c
42.355999 19.999998 l
42.169334 19.999998 42.015999 19.939999 41.896 19.819998 c
41.736 19.673332 41.655998 19.499998 41.655998 19.299997 c
41.655998 17.859997 l
41.655998 17.686663 41.716 17.533331 41.835999 17.399998 c
41.955997 17.266665 42.102665 17.186665 42.275997 17.159998 c
42.902664 17.09333 43.389328 16.946665 43.735996 16.719997 c
44.002663 16.559998 44.182663 16.353331 44.275997 16.099997 c
44.32933 15.95333 44.355999 15.766663 44.355999 15.539996 c
44.355999 15.39333 44.32933 15.266663 44.275997 15.159996 c
44.222664 15.05333 44.12933 14.953329 43.995998 14.859996 c
43.609329 14.593329 43.109329 14.366663 42.495998 14.179996 c
42.175999 14.079995 l
41.509331 13.893329 40.896 13.739995 40.335999 13.619995 c
40.216 13.593328 40.022663 13.546661 39.755997 13.479995 c
39.535995 13.419994 l
39.055996 13.299995 38.549328 13.133327 38.015995 12.919994 c
37.229328 12.586661 36.582661 12.166661 36.075996 11.659994 c
35.48933 11.073327 35.195995 10.299995 35.195995 9.339994 c
35.195995 8.019995 35.702663 6.946661 36.715996 6.119994 c
37.409328 5.533328 38.382664 5.159994 39.635994 4.999994 c
39.849327 4.973328 40.032661 5.033328 40.185993 5.179994 c
40.339325 5.326661 40.415993 5.499994 40.415993 5.699994 c
40.415993 7.139994 l
40.415993 7.313327 40.362659 7.466661 40.255993 7.599994 c
40.149326 7.733328 40.012661 7.809994 39.845993 7.829994 c
39.679325 7.849994 39.509327 7.886661 39.335995 7.939994 c
38.949326 8.08666 38.675995 8.229994 38.515995 8.369994 c
38.355995 8.509995 38.242664 8.653328 38.175995 8.799995 c
38.109329 9.026661 38.075996 9.239995 38.075996 9.439995 c
38.075996 9.546661 38.135998 9.659995 38.255997 9.779995 c
38.442661 9.966662 38.715996 10.133328 39.075996 10.279995 c
39.235996 10.346662 39.515999 10.446662 39.915997 10.579995 c
42.415997 11.199995 l
42.535995 11.239995 l
43.162663 11.413328 43.615993 11.546661 43.895996 11.639995 c
44.522663 11.853328 45.069328 12.113328 45.535995 12.419994 c
45.535999 12.42 l
h
42.195999 7.9 m
42.035999 7.86 41.905998 7.776667 41.806 7.65 c
41.706001 7.523334 41.656002 7.373334 41.655998 7.2 c
41.655998 5.74 l
41.655998 5.526667 41.742664 5.346667 41.915997 5.2 c
41.982662 5.133334 42.069328 5.09 42.175995 5.07 c
42.282661 5.05 42.38266 5.046667 42.475994 5.06 c
43.649326 5.246667 44.602661 5.646667 45.335995 6.26 c
46.335995 7.086667 46.869328 8.14 46.935993 9.42 c
46.949326 9.606667 46.885994 9.776667 46.745995 9.93 c
46.605995 10.083334 46.435997 10.160001 46.235996 10.16 c
44.675995 10.16 l
44.502663 10.16 44.349331 10.099999 44.215996 9.98 c
44.082661 9.86 44.009327 9.713333 43.995995 9.54 c
43.942661 9.193334 43.829327 8.906667 43.655994 8.68 c
43.375996 8.346667 42.942661 8.093333 42.355995 7.92 c
42.315994 7.92 42.289326 7.913333 42.275993 7.9 c
42.195992 7.9 l
42.195999 7.9 l
h
39.855999 17.08 m
40.015999 17.106667 40.149334 17.186666 40.256001 17.32 c
40.362667 17.453333 40.416 17.599998 40.416 17.76 c
40.416 19.24 l
40.416 19.453333 40.335999 19.633333 40.175999 19.780001 c
40.042664 19.886667 39.889332 19.940001 39.716 19.940001 c
39.616001 19.940001 l
38.375999 19.753334 37.355999 19.346666 36.556 18.720001 c
35.515999 17.893335 34.929333 16.740002 34.796001 15.260001 c
34.769333 15.046668 34.829334 14.863335 34.976002 14.710001 c
35.122669 14.556667 35.296001 14.480001 35.496002 14.480001 c
37.076004 14.480001 l
37.262669 14.480001 37.422668 14.543335 37.556004 14.670001 c
37.689339 14.796667 37.762672 14.953334 37.776005 15.140001 c
37.816006 15.806668 38.142673 16.320002 38.756004 16.68 c
39.036003 16.84 39.402668 16.973333 39.856003 17.08 c
39.855999 17.08 l
h
60.883999 11.12 m
61.084 11.12 61.253998 11.19 61.393997 11.33 c
61.533997 11.47 61.603996 11.64 61.603996 11.84 c
61.603996 13.32 l
61.603996 13.506666 61.533997 13.669999 61.393997 13.81 c
61.253998 13.95 61.084 14.02 60.883999 14.02 c
54.304001 14.02 l
54.304001 19.139999 l
54.304001 19.326666 54.237335 19.49 54.104 19.629999 c
53.970665 19.769999 53.804001 19.839998 53.604 19.839998 c
52.084 19.839998 l
51.897335 19.839998 51.734001 19.769999 51.593998 19.629999 c
51.453995 19.49 51.383995 19.326666 51.383999 19.139999 c
51.383999 11.839999 l
51.383999 11.639999 51.453999 11.469999 51.593998 11.329999 c
51.733997 11.189999 51.897331 11.119999 52.084 11.119999 c
60.883999 11.119999 l
60.883999 11.12 l
h
61.784 5 m
61.970665 5 62.133999 5.07 62.274002 5.21 c
62.414005 5.35 62.484005 5.513333 62.484001 5.7 c
62.484001 7.16 l
62.484001 7.36 62.414001 7.53 62.274002 7.67 c
62.134003 7.81 61.970669 7.88 61.784 7.88 c
52.084 7.88 l
51.897335 7.88 51.734001 7.81 51.593998 7.67 c
51.453995 7.53 51.383995 7.36 51.383999 7.16 c
51.383999 5.7 l
51.383999 5.513333 51.453999 5.35 51.593998 5.21 c
51.733997 5.07 51.897331 5.000001 52.084 5 c
61.784 5 l
h
79.512001 18.860001 m
79.605331 19.073334 79.582001 19.290001 79.442001 19.51 c
79.302002 19.73 79.112 19.84 78.872002 19.84 c
65.552002 19.84 l
65.431999 19.84 65.318672 19.809999 65.212006 19.75 c
65.105339 19.690001 65.025345 19.613333 64.972008 19.52 c
64.82534 19.306667 64.805344 19.086668 64.91201 18.860001 c
65.532013 17.400002 l
65.58535 17.266668 65.672012 17.160002 65.792015 17.080002 c
65.912018 17.000002 66.038681 16.960003 66.172012 16.960001 c
75.492012 16.960001 l
72.212013 9.120001 l
69.532013 15.520001 l
69.478676 15.653334 69.395348 15.756667 69.282013 15.830001 c
69.168678 15.903335 69.038681 15.940002 68.892014 15.940001 c
67.212013 15.940001 l
66.958679 15.940001 66.758682 15.833334 66.612015 15.620001 c
66.558678 15.526668 66.525345 15.420001 66.512016 15.300001 c
66.498688 15.180001 66.512016 15.066669 66.552017 14.960001 c
70.612015 5.440001 l
70.665352 5.306667 70.74868 5.200001 70.862015 5.12 c
70.975349 5.04 71.105347 5 71.252014 5.000001 c
73.172012 5.000001 l
73.31868 5.000001 73.448677 5.04 73.562012 5.12 c
73.675346 5.2 73.758675 5.306667 73.812012 5.440001 c
79.512001 18.860001 l
h
92 5.52 m
92.813332 5.826667 93.546669 6.32 94.199997 7 c
95.599998 8.413333 96.299995 10.216666 96.299995 12.41 c
96.299995 14.603334 95.599998 16.413334 94.199997 17.84 c
93.546661 18.52 92.813332 19.013334 92 19.32 c
91.159996 19.666666 90.299995 19.84 89.419998 19.84 c
83.759995 19.84 l
83.573326 19.84 83.409996 19.77 83.269997 19.630001 c
83.129997 19.490002 83.059998 19.326668 83.059998 19.140001 c
83.059998 17.700001 l
83.059998 17.500002 83.129997 17.330002 83.269997 17.190001 c
83.409996 17.049999 83.573326 16.98 83.759995 16.980001 c
89.359993 16.980001 l
90.546661 16.980001 91.509995 16.546669 92.249992 15.680001 c
92.98999 14.813335 93.359993 13.726667 93.359993 12.420001 c
93.359993 11.113335 92.98999 10.026668 92.249992 9.160001 c
91.509995 8.293334 90.546661 7.860001 89.359993 7.860001 c
83.759995 7.860001 l
83.573326 7.860001 83.409996 7.79 83.269997 7.650001 c
83.129997 7.510001 83.059998 7.340001 83.059998 7.14 c
83.059998 5.7 l
83.059998 5.513333 83.129997 5.35 83.269997 5.21 c
83.409996 5.07 83.573326 5 83.759995 5 c
89.419998 5 l
90.299995 5 91.159996 5.173333 92 5.52 c
h
f
Q
Q
Q
showpage
%%PageTrailer
pdfEndPage
%%Trailer
end
%%DocumentSuppliedResources:
%%EOF

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,920 @@
%!PS-Adobe-3.0 EPSF-3.0
%Produced by poppler pdftops version: 22.05.0 (http://poppler.freedesktop.org)
%%Creator: Chromium
%%LanguageLevel: 3
%%DocumentSuppliedResources: (atend)
%%BoundingBox: 0 0 2400 1018
%%HiResBoundingBox: 0 0 2400 1017.12
%%DocumentSuppliedResources: (atend)
%%EndComments
%%BeginProlog
%%BeginResource: procset xpdf 3.00 0
%%Copyright: Copyright 1996-2011, 2022 Glyph & Cog, LLC
/xpdf 75 dict def xpdf begin
% PDF special state
/pdfDictSize 15 def
/pdfSetup {
/setpagedevice where {
pop 2 dict begin
/Policies 1 dict dup begin /PageSize 6 def end def
{ /Duplex true def } if
currentdict end setpagedevice
} {
pop
} ifelse
} def
/pdfSetupPaper {
% Change paper size, but only if different from previous paper size otherwise
% duplex fails. PLRM specifies a tolerance of 5 pts when matching paper size
% so we use the same when checking if the size changes.
/setpagedevice where {
pop currentpagedevice
/PageSize known {
2 copy
currentpagedevice /PageSize get aload pop
exch 4 1 roll
sub abs 5 gt
3 1 roll
sub abs 5 gt
or
} {
true
} ifelse
{
2 array astore
2 dict begin
/PageSize exch def
/ImagingBBox null def
currentdict end
setpagedevice
} {
pop pop
} ifelse
} {
pop
} ifelse
} def
/pdfStartPage {
pdfDictSize dict begin
/pdfFillCS [] def
/pdfFillXform {} def
/pdfStrokeCS [] def
/pdfStrokeXform {} def
/pdfFill [0] def
/pdfStroke [0] def
/pdfFillOP false def
/pdfStrokeOP false def
/pdfOPM false def
/pdfLastFill false def
/pdfLastStroke false def
/pdfTextMat [1 0 0 1 0 0] def
/pdfFontSize 0 def
/pdfCharSpacing 0 def
/pdfTextRender 0 def
/pdfPatternCS false def
/pdfTextRise 0 def
/pdfWordSpacing 0 def
/pdfHorizScaling 1 def
/pdfTextClipPath [] def
} def
/pdfEndPage { end } def
% PDF color state
/opm { dup /pdfOPM exch def
/setoverprintmode where{pop setoverprintmode}{pop}ifelse } def
/cs { /pdfFillXform exch def dup /pdfFillCS exch def
setcolorspace } def
/CS { /pdfStrokeXform exch def dup /pdfStrokeCS exch def
setcolorspace } def
/sc { pdfLastFill not { pdfFillCS setcolorspace } if
dup /pdfFill exch def aload pop pdfFillXform setcolor
/pdfLastFill true def /pdfLastStroke false def } def
/SC { pdfLastStroke not { pdfStrokeCS setcolorspace } if
dup /pdfStroke exch def aload pop pdfStrokeXform setcolor
/pdfLastStroke true def /pdfLastFill false def } def
/op { /pdfFillOP exch def
pdfLastFill { pdfFillOP setoverprint } if } def
/OP { /pdfStrokeOP exch def
pdfLastStroke { pdfStrokeOP setoverprint } if } def
/fCol {
pdfLastFill not {
pdfFillCS setcolorspace
pdfFill aload pop pdfFillXform setcolor
pdfFillOP setoverprint
/pdfLastFill true def /pdfLastStroke false def
} if
} def
/sCol {
pdfLastStroke not {
pdfStrokeCS setcolorspace
pdfStroke aload pop pdfStrokeXform setcolor
pdfStrokeOP setoverprint
/pdfLastStroke true def /pdfLastFill false def
} if
} def
% build a font
/pdfMakeFont {
4 3 roll findfont
4 2 roll matrix scale makefont
dup length dict begin
{ 1 index /FID ne { def } { pop pop } ifelse } forall
/Encoding exch def
currentdict
end
definefont pop
} def
/pdfMakeFont16 {
exch findfont
dup length dict begin
{ 1 index /FID ne { def } { pop pop } ifelse } forall
/WMode exch def
currentdict
end
definefont pop
} def
/pdfMakeFont16L3 {
1 index /CIDFont resourcestatus {
pop pop 1 index /CIDFont findresource /CIDFontType known
} {
false
} ifelse
{
0 eq { /Identity-H } { /Identity-V } ifelse
exch 1 array astore composefont pop
} {
pdfMakeFont16
} ifelse
} def
% graphics state operators
/q { gsave pdfDictSize dict begin } def
/Q {
end grestore
/pdfLastFill where {
pop
pdfLastFill {
pdfFillOP setoverprint
} {
pdfStrokeOP setoverprint
} ifelse
} if
/pdfOPM where {
pop
pdfOPM /setoverprintmode where{pop setoverprintmode}{pop}ifelse
} if
} def
/cm { concat } def
/d { setdash } def
/i { setflat } def
/j { setlinejoin } def
/J { setlinecap } def
/M { setmiterlimit } def
/w { setlinewidth } def
% path segment operators
/m { moveto } def
/l { lineto } def
/c { curveto } def
/re { 4 2 roll moveto 1 index 0 rlineto 0 exch rlineto
neg 0 rlineto closepath } def
/h { closepath } def
% path painting operators
/S { sCol stroke } def
/Sf { fCol stroke } def
/f { fCol fill } def
/f* { fCol eofill } def
% clipping operators
/W { clip newpath } def
/W* { eoclip newpath } def
/Ws { strokepath clip newpath } def
% text state operators
/Tc { /pdfCharSpacing exch def } def
/Tf { dup /pdfFontSize exch def
dup pdfHorizScaling mul exch matrix scale
pdfTextMat matrix concatmatrix dup 4 0 put dup 5 0 put
exch findfont exch makefont setfont } def
/Tr { /pdfTextRender exch def } def
/Tp { /pdfPatternCS exch def } def
/Ts { /pdfTextRise exch def } def
/Tw { /pdfWordSpacing exch def } def
/Tz { /pdfHorizScaling exch def } def
% text positioning operators
/Td { pdfTextMat transform moveto } def
/Tm { /pdfTextMat exch def } def
% text string operators
/xyshow where {
pop
/xyshow2 {
dup length array
0 2 2 index length 1 sub {
2 index 1 index 2 copy get 3 1 roll 1 add get
pdfTextMat dtransform
4 2 roll 2 copy 6 5 roll put 1 add 3 1 roll dup 4 2 roll put
} for
exch pop
xyshow
} def
}{
/xyshow2 {
currentfont /FontType get 0 eq {
0 2 3 index length 1 sub {
currentpoint 4 index 3 index 2 getinterval show moveto
2 copy get 2 index 3 2 roll 1 add get
pdfTextMat dtransform rmoveto
} for
} {
0 1 3 index length 1 sub {
currentpoint 4 index 3 index 1 getinterval show moveto
2 copy 2 mul get 2 index 3 2 roll 2 mul 1 add get
pdfTextMat dtransform rmoveto
} for
} ifelse
pop pop
} def
} ifelse
/cshow where {
pop
/xycp {
0 3 2 roll
{
pop pop currentpoint 3 2 roll
1 string dup 0 4 3 roll put false charpath moveto
2 copy get 2 index 2 index 1 add get
pdfTextMat dtransform rmoveto
2 add
} exch cshow
pop pop
} def
}{
/xycp {
currentfont /FontType get 0 eq {
0 2 3 index length 1 sub {
currentpoint 4 index 3 index 2 getinterval false charpath moveto
2 copy get 2 index 3 2 roll 1 add get
pdfTextMat dtransform rmoveto
} for
} {
0 1 3 index length 1 sub {
currentpoint 4 index 3 index 1 getinterval false charpath moveto
2 copy 2 mul get 2 index 3 2 roll 2 mul 1 add get
pdfTextMat dtransform rmoveto
} for
} ifelse
pop pop
} def
} ifelse
/Tj {
fCol
0 pdfTextRise pdfTextMat dtransform rmoveto
currentpoint 4 2 roll
pdfTextRender 1 and 0 eq {
2 copy xyshow2
} if
pdfTextRender 3 and dup 1 eq exch 2 eq or {
3 index 3 index moveto
2 copy
currentfont /FontType get 3 eq { fCol } { sCol } ifelse
xycp currentpoint stroke moveto
} if
pdfTextRender 4 and 0 ne {
4 2 roll moveto xycp
/pdfTextClipPath [ pdfTextClipPath aload pop
{/moveto cvx}
{/lineto cvx}
{/curveto cvx}
{/closepath cvx}
pathforall ] def
currentpoint newpath moveto
} {
pop pop pop pop
} ifelse
0 pdfTextRise neg pdfTextMat dtransform rmoveto
} def
/TJm { 0.001 mul pdfFontSize mul pdfHorizScaling mul neg 0
pdfTextMat dtransform rmoveto } def
/TJmV { 0.001 mul pdfFontSize mul neg 0 exch
pdfTextMat dtransform rmoveto } def
/Tclip { pdfTextClipPath cvx exec clip newpath
/pdfTextClipPath [] def } def
/Tclip* { pdfTextClipPath cvx exec eoclip newpath
/pdfTextClipPath [] def } def
% Level 2/3 image operators
/pdfImBuf 100 string def
/pdfImStr {
2 copy exch length lt {
2 copy get exch 1 add exch
} {
()
} ifelse
} def
/skipEOD {
{ currentfile pdfImBuf readline
not { pop exit } if
(%-EOD-) eq { exit } if } loop
} def
/pdfIm { image skipEOD } def
/pdfMask {
/ReusableStreamDecode filter
skipEOD
/maskStream exch def
} def
/pdfMaskEnd { maskStream closefile } def
/pdfMaskInit {
/maskArray exch def
/maskIdx 0 def
} def
/pdfMaskSrc {
maskIdx maskArray length lt {
maskArray maskIdx get
/maskIdx maskIdx 1 add def
} {
()
} ifelse
} def
/pdfImM { fCol imagemask skipEOD } def
/pr { 2 index 2 index 3 2 roll putinterval 4 add } def
/pdfImClip {
gsave
0 2 4 index length 1 sub {
dup 4 index exch 2 copy
get 5 index div put
1 add 3 index exch 2 copy
get 3 index div put
} for
pop pop rectclip
} def
/pdfImClipEnd { grestore } def
% shading operators
/colordelta {
false 0 1 3 index length 1 sub {
dup 4 index exch get 3 index 3 2 roll get sub abs 0.004 gt {
pop true
} if
} for
exch pop exch pop
} def
/funcCol { func n array astore } def
/funcSH {
dup 0 eq {
true
} {
dup 6 eq {
false
} {
4 index 4 index funcCol dup
6 index 4 index funcCol dup
3 1 roll colordelta 3 1 roll
5 index 5 index funcCol dup
3 1 roll colordelta 3 1 roll
6 index 8 index funcCol dup
3 1 roll colordelta 3 1 roll
colordelta or or or
} ifelse
} ifelse
{
1 add
4 index 3 index add 0.5 mul exch 4 index 3 index add 0.5 mul exch
6 index 6 index 4 index 4 index 4 index funcSH
2 index 6 index 6 index 4 index 4 index funcSH
6 index 2 index 4 index 6 index 4 index funcSH
5 3 roll 3 2 roll funcSH pop pop
} {
pop 3 index 2 index add 0.5 mul 3 index 2 index add 0.5 mul
funcCol sc
dup 4 index exch mat transform m
3 index 3 index mat transform l
1 index 3 index mat transform l
mat transform l pop pop h f*
} ifelse
} def
/axialCol {
dup 0 lt {
pop t0
} {
dup 1 gt {
pop t1
} {
dt mul t0 add
} ifelse
} ifelse
func n array astore
} def
/axialSH {
dup 0 eq {
true
} {
dup 8 eq {
false
} {
2 index axialCol 2 index axialCol colordelta
} ifelse
} ifelse
{
1 add 3 1 roll 2 copy add 0.5 mul
dup 4 3 roll exch 4 index axialSH
exch 3 2 roll axialSH
} {
pop 2 copy add 0.5 mul
axialCol sc
exch dup dx mul x0 add exch dy mul y0 add
3 2 roll dup dx mul x0 add exch dy mul y0 add
dx abs dy abs ge {
2 copy yMin sub dy mul dx div add yMin m
yMax sub dy mul dx div add yMax l
2 copy yMax sub dy mul dx div add yMax l
yMin sub dy mul dx div add yMin l
h f*
} {
exch 2 copy xMin sub dx mul dy div add xMin exch m
xMax sub dx mul dy div add xMax exch l
exch 2 copy xMax sub dx mul dy div add xMax exch l
xMin sub dx mul dy div add xMin exch l
h f*
} ifelse
} ifelse
} def
/radialCol {
dup t0 lt {
pop t0
} {
dup t1 gt {
pop t1
} if
} ifelse
func n array astore
} def
/radialSH {
dup 0 eq {
true
} {
dup 8 eq {
false
} {
2 index dt mul t0 add radialCol
2 index dt mul t0 add radialCol colordelta
} ifelse
} ifelse
{
1 add 3 1 roll 2 copy add 0.5 mul
dup 4 3 roll exch 4 index radialSH
exch 3 2 roll radialSH
} {
pop 2 copy add 0.5 mul dt mul t0 add
radialCol sc
encl {
exch dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
0 360 arc h
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
360 0 arcn h f
} {
2 copy
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
a1 a2 arcn
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
a2 a1 arcn h
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
a1 a2 arc
dup dx mul x0 add exch dup dy mul y0 add exch dr mul r0 add
a2 a1 arc h f
} ifelse
} ifelse
} def
end
%%EndResource
/CIDInit /ProcSet findresource begin
10 dict begin
begincmap
/CMapType 1 def
/CMapName /Identity-H def
/CIDSystemInfo 3 dict dup begin
/Registry (Adobe) def
/Ordering (Identity) def
/Supplement 0 def
end def
1 begincodespacerange
<0000> <ffff>
endcodespacerange
0 usefont
1 begincidrange
<0000> <ffff> 0
endcidrange
endcmap
currentdict CMapName exch /CMap defineresource pop
end
10 dict begin
begincmap
/CMapType 1 def
/CMapName /Identity-V def
/CIDSystemInfo 3 dict dup begin
/Registry (Adobe) def
/Ordering (Identity) def
/Supplement 0 def
end def
/WMode 1 def
1 begincodespacerange
<0000> <ffff>
endcodespacerange
0 usefont
1 begincidrange
<0000> <ffff> 0
endcidrange
endcmap
currentdict CMapName exch /CMap defineresource pop
end
end
%%EndProlog
%%BeginSetup
xpdf begin
%%EndSetup
pdfStartPage
%%EndPageSetup
[] 0 d
1 i
0 j
0 J
10 M
1 w
/DeviceGray {} cs
[0] sc
/DeviceGray {} CS
[0] SC
false op
false OP
{} settransfer
0 0 2400 1017.12 re
W
q
[0.24 0 0 -0.24 0 1017.12] cm
q
0 0 10000 4234.375 re
W*
q
[48.783241 0 0 48.766369 2560.8376 -1369.56274] cm
/DeviceRGB {} CS
[1 1 1] SC
/DeviceRGB {} cs
[1 1 1] sc
29.399 57.112 m
30.014 57.419998 30.476 57.958 30.476 58.494999 c
30.476 59.534 29.438 60.455997 28.476999 59.957001 c
13.254 52.076 l
12.408 51.577 11.678 51.268002 11.678 50.192001 c
11.678 49.077 12.37 48.807003 13.254 48.27 c
28.476999 40.389 l
29.514999 40.042999 30.476 40.813 30.476 41.851002 c
30.476 42.426003 30.014999 42.965 29.399 43.274002 c
15.638 50.192001 l
29.399 57.112 l
h
f
29.033001 60.209 m
28.825001 60.209 28.620001 60.157001 28.425001 60.056999 c
13.202002 52.175999 l
13.116001 52.125999 13.037002 52.080997 12.960002 52.035 c
12.212002 51.604 11.565002 51.230999 11.565002 50.192001 c
11.565002 49.135002 12.159002 48.786003 12.911002 48.343002 c
13.004003 48.289001 13.098002 48.233002 13.195003 48.174004 c
28.424004 40.289001 l
28.619003 40.223 28.801004 40.192001 28.982004 40.192001 c
29.882004 40.192001 30.588005 40.920002 30.588005 41.850002 c
30.588005 42.437004 30.151005 43.022003 29.449005 43.372002 c
15.887005 50.190002 l
29.449005 57.008003 l
30.140005 57.354004 30.588005 57.938004 30.588005 58.492004 c
30.587999 59.407001 29.861 60.209 29.033001 60.209 c
h
28.982 40.418999 m
28.826 40.418999 28.668001 40.445 28.512001 40.496998 c
13.306 48.369999 l
13.215 48.426998 13.118 48.482998 13.025 48.536999 c
12.282 48.975998 11.790999 49.264999 11.790999 50.191998 c
11.790999 51.101997 12.327999 51.410999 13.072 51.839996 c
13.151 51.884995 13.23 51.929996 13.311 51.978996 c
28.528 59.855995 l
28.690001 59.939995 28.860001 59.982994 29.032 59.982994 c
29.729 59.982994 30.362 59.273994 30.362 58.494995 c
30.362 58.029995 29.955 57.514996 29.348 57.211994 c
15.386 50.191994 l
29.348 43.171993 l
29.963999 42.863995 30.362 42.343994 30.362 41.850994 c
30.363001 41.048 29.756001 40.418999 28.982 40.418999 c
h
f
46.384998 64.416 m
46.153999 65.107002 45.462997 65.493004 44.771 65.376999 c
44.001999 65.223999 43.501999 64.491997 43.617001 63.684998 c
43.617001 63.608997 43.656002 63.493996 43.694 63.377998 c
53.574001 35.546997 l
53.804001 34.854996 54.496002 34.508999 55.188 34.623997 c
55.917999 34.777996 56.457001 35.508995 56.341 36.275997 c
56.341 36.353996 56.301998 36.468998 56.264 36.545998 c
46.384998 64.416 l
h
f
45.015999 65.511002 m
44.927998 65.511002 44.839001 65.502998 44.752998 65.488998 c
43.915997 65.321999 43.382 64.540001 43.505997 63.669998 c
43.504997 63.593998 43.543995 63.476997 43.584995 63.351997 c
53.467995 35.509998 l
53.674995 34.891998 54.245995 34.489998 54.924995 34.489998 c
55.018997 34.489998 55.112995 34.498997 55.205994 34.512997 c
56.017994 34.684998 56.574993 35.482998 56.452995 36.293995 c
56.453995 36.379993 56.405994 36.513996 56.365993 36.595993 c
46.490993 64.451996 l
46.280998 65.084 45.688 65.511002 45.015999 65.511002 c
h
54.924999 34.715 m
54.344997 34.715 53.856998 35.055 53.681 35.582001 c
43.800999 63.415001 l
43.765999 63.519001 43.730999 63.628002 43.730999 63.685001 c
43.622997 64.452003 44.079998 65.124001 44.792999 65.266998 c
44.863998 65.278999 44.939999 65.284996 45.015999 65.284996 c
45.591 65.284996 46.098999 64.921997 46.278999 64.380997 c
56.16 36.508995 l
56.201 36.423996 56.23 36.326996 56.23 36.277996 c
56.334999 35.565994 55.856998 34.881996 55.166 34.734997 c
55.089001 34.722 55.007 34.715 54.924999 34.715 c
h
f
84.362 50.192001 m
70.599998 43.273998 l
69.986 42.964996 69.525002 42.425999 69.525002 41.850998 c
69.525002 40.813 70.562004 39.888996 71.523003 40.388996 c
86.746002 48.269997 l
87.631004 48.806995 88.321999 49.076996 88.321999 50.191998 c
88.321999 51.267998 87.591995 51.576996 86.746002 52.075996 c
71.523003 59.956997 l
70.562004 60.455997 69.525002 59.533997 69.525002 58.494995 c
69.525002 57.957996 69.986 57.419994 70.599998 57.111996 c
84.362 50.192001 l
h
f
70.967003 60.209 m
70.139 60.209 69.411003 59.407001 69.411003 58.494999 c
69.411003 57.939999 69.858002 57.355999 70.550003 57.010998 c
84.112 50.192997 l
70.550003 43.374001 l
69.848 43.021999 69.411003 42.438 69.411003 41.852001 c
69.411003 40.939003 70.139 40.138 70.967003 40.138 c
71.176003 40.138 71.380005 40.188999 71.575005 40.290001 c
86.798004 48.171001 l
86.902 48.233002 86.996002 48.290001 87.088005 48.344002 c
87.841003 48.786003 88.435005 49.136002 88.435005 50.193001 c
88.435005 51.232002 87.789001 51.605 87.040009 52.035999 c
86.962006 52.082001 86.883011 52.126999 86.803009 52.174 c
71.575012 60.057999 l
71.379997 60.157001 71.176003 60.209 70.967003 60.209 c
h
70.967003 40.362999 m
70.271004 40.362999 69.637001 41.071999 69.637001 41.850998 c
69.637001 42.343998 70.034004 42.863998 70.651001 43.171997 c
84.612999 50.191998 l
70.651001 57.211998 l
70.044998 57.516998 69.637001 58.031998 69.637001 58.494999 c
69.637001 59.273998 70.271004 59.982998 70.967003 59.982998 c
71.139 59.982998 71.309006 59.939999 71.471001 59.855999 c
86.694 51.975998 l
86.768997 51.929996 86.848999 51.884998 86.927002 51.839996 c
87.671005 51.409996 88.209 51.101997 88.209 50.191998 c
88.209 49.263996 87.718002 48.975998 86.973999 48.536999 c
86.880997 48.482998 86.785995 48.426998 86.686996 48.367001 c
71.470993 40.491001 l
71.308998 40.404999 71.139 40.362999 70.967003 40.362999 c
h
f
Q
q
[99.016907 0 0 98.982658 152.13266 1942.3326] cm
/DeviceRGB {} CS
[1 1 1] SC
/DeviceRGB {} cs
[1 1 1] sc
10.56 5.52 m
11.373334 5.826667 12.106668 6.32 12.76 7 c
14.160001 8.413333 14.860001 10.216666 14.860001 12.41 c
14.860001 14.603334 14.160001 16.413334 12.76 17.84 c
12.106668 18.52 11.373334 19.013334 10.56 19.32 c
9.72 19.666666 8.860001 19.84 7.980001 19.84 c
2.320001 19.84 l
2.133334 19.84 1.970001 19.77 1.830001 19.630001 c
1.690001 19.490002 1.620001 19.326668 1.620001 19.140001 c
1.620001 17.700001 l
1.620001 17.500002 1.690001 17.330002 1.830001 17.190001 c
1.970001 17.049999 2.133334 16.98 2.320001 16.980001 c
7.920001 16.980001 l
9.106668 16.980001 10.070001 16.546669 10.81 15.680001 c
11.55 14.813335 11.92 13.726667 11.92 12.420001 c
11.92 11.113335 11.55 10.026668 10.81 9.160001 c
10.070001 8.293334 9.106668 7.860001 7.920001 7.860001 c
2.320001 7.860001 l
2.133334 7.860001 1.970001 7.79 1.830001 7.650001 c
1.690001 7.510001 1.620001 7.340001 1.620001 7.14 c
1.620001 5.7 l
1.620001 5.513333 1.690001 5.35 1.830001 5.21 c
1.970001 5.07 2.133334 5 2.320001 5 c
7.980001 5 l
8.860001 5 9.72 5.173333 10.56 5.52 c
h
32.208 18.860001 m
32.301334 19.073334 32.278 19.290001 32.138 19.51 c
31.998001 19.73 31.808001 19.84 31.568001 19.84 c
18.248001 19.84 l
18.128 19.84 18.014668 19.809999 17.908001 19.75 c
17.801334 19.690001 17.721334 19.613333 17.668001 19.52 c
17.521336 19.306667 17.501335 19.086668 17.608002 18.860001 c
18.228003 17.400002 l
18.281336 17.266668 18.368002 17.160002 18.488003 17.080002 c
18.608004 17.000002 18.734669 16.960003 18.868002 16.960001 c
28.188002 16.960001 l
24.908001 9.120001 l
22.228001 15.520001 l
22.174667 15.653334 22.091333 15.756667 21.978001 15.830001 c
21.864668 15.903335 21.734667 15.940002 21.588001 15.940001 c
19.908001 15.940001 l
19.654669 15.940001 19.454668 15.833334 19.308001 15.620001 c
19.254667 15.526668 19.221334 15.420001 19.208 15.300001 c
19.194666 15.180001 19.208 15.066669 19.248001 14.960001 c
23.308001 5.440001 l
23.361334 5.306667 23.444668 5.200001 23.558001 5.12 c
23.671333 5.04 23.801334 5 23.948 5.000001 c
25.868 5.000001 l
26.014666 5.000001 26.144667 5.04 26.257999 5.12 c
26.371332 5.2 26.454666 5.306667 26.507999 5.440001 c
32.208 18.860001 l
h
45.535999 12.42 m
46.109333 12.78 46.546001 13.22 46.846001 13.74 c
47.146 14.259999 47.296001 14.839999 47.296001 15.48 c
47.296001 16.986666 46.722668 18.139999 45.576 18.939999 c
44.802666 19.499998 43.756001 19.853333 42.436001 19.999998 c
42.355999 19.999998 l
42.169334 19.999998 42.015999 19.939999 41.896 19.819998 c
41.736 19.673332 41.655998 19.499998 41.655998 19.299997 c
41.655998 17.859997 l
41.655998 17.686663 41.716 17.533331 41.835999 17.399998 c
41.955997 17.266665 42.102665 17.186665 42.275997 17.159998 c
42.902664 17.09333 43.389328 16.946665 43.735996 16.719997 c
44.002663 16.559998 44.182663 16.353331 44.275997 16.099997 c
44.32933 15.95333 44.355999 15.766663 44.355999 15.539996 c
44.355999 15.39333 44.32933 15.266663 44.275997 15.159996 c
44.222664 15.05333 44.12933 14.953329 43.995998 14.859996 c
43.609329 14.593329 43.109329 14.366663 42.495998 14.179996 c
42.175999 14.079995 l
41.509331 13.893329 40.896 13.739995 40.335999 13.619995 c
40.216 13.593328 40.022663 13.546661 39.755997 13.479995 c
39.535995 13.419994 l
39.055996 13.299995 38.549328 13.133327 38.015995 12.919994 c
37.229328 12.586661 36.582661 12.166661 36.075996 11.659994 c
35.48933 11.073327 35.195995 10.299995 35.195995 9.339994 c
35.195995 8.019995 35.702663 6.946661 36.715996 6.119994 c
37.409328 5.533328 38.382664 5.159994 39.635994 4.999994 c
39.849327 4.973328 40.032661 5.033328 40.185993 5.179994 c
40.339325 5.326661 40.415993 5.499994 40.415993 5.699994 c
40.415993 7.139994 l
40.415993 7.313327 40.362659 7.466661 40.255993 7.599994 c
40.149326 7.733328 40.012661 7.809994 39.845993 7.829994 c
39.679325 7.849994 39.509327 7.886661 39.335995 7.939994 c
38.949326 8.08666 38.675995 8.229994 38.515995 8.369994 c
38.355995 8.509995 38.242664 8.653328 38.175995 8.799995 c
38.109329 9.026661 38.075996 9.239995 38.075996 9.439995 c
38.075996 9.546661 38.135998 9.659995 38.255997 9.779995 c
38.442661 9.966662 38.715996 10.133328 39.075996 10.279995 c
39.235996 10.346662 39.515999 10.446662 39.915997 10.579995 c
42.415997 11.199995 l
42.535995 11.239995 l
43.162663 11.413328 43.615993 11.546661 43.895996 11.639995 c
44.522663 11.853328 45.069328 12.113328 45.535995 12.419994 c
45.535999 12.42 l
h
42.195999 7.9 m
42.035999 7.86 41.905998 7.776667 41.806 7.65 c
41.706001 7.523334 41.656002 7.373334 41.655998 7.2 c
41.655998 5.74 l
41.655998 5.526667 41.742664 5.346667 41.915997 5.2 c
41.982662 5.133334 42.069328 5.09 42.175995 5.07 c
42.282661 5.05 42.38266 5.046667 42.475994 5.06 c
43.649326 5.246667 44.602661 5.646667 45.335995 6.26 c
46.335995 7.086667 46.869328 8.14 46.935993 9.42 c
46.949326 9.606667 46.885994 9.776667 46.745995 9.93 c
46.605995 10.083334 46.435997 10.160001 46.235996 10.16 c
44.675995 10.16 l
44.502663 10.16 44.349331 10.099999 44.215996 9.98 c
44.082661 9.86 44.009327 9.713333 43.995995 9.54 c
43.942661 9.193334 43.829327 8.906667 43.655994 8.68 c
43.375996 8.346667 42.942661 8.093333 42.355995 7.92 c
42.315994 7.92 42.289326 7.913333 42.275993 7.9 c
42.195992 7.9 l
42.195999 7.9 l
h
39.855999 17.08 m
40.015999 17.106667 40.149334 17.186666 40.256001 17.32 c
40.362667 17.453333 40.416 17.599998 40.416 17.76 c
40.416 19.24 l
40.416 19.453333 40.335999 19.633333 40.175999 19.780001 c
40.042664 19.886667 39.889332 19.940001 39.716 19.940001 c
39.616001 19.940001 l
38.375999 19.753334 37.355999 19.346666 36.556 18.720001 c
35.515999 17.893335 34.929333 16.740002 34.796001 15.260001 c
34.769333 15.046668 34.829334 14.863335 34.976002 14.710001 c
35.122669 14.556667 35.296001 14.480001 35.496002 14.480001 c
37.076004 14.480001 l
37.262669 14.480001 37.422668 14.543335 37.556004 14.670001 c
37.689339 14.796667 37.762672 14.953334 37.776005 15.140001 c
37.816006 15.806668 38.142673 16.320002 38.756004 16.68 c
39.036003 16.84 39.402668 16.973333 39.856003 17.08 c
39.855999 17.08 l
h
60.883999 11.12 m
61.084 11.12 61.253998 11.19 61.393997 11.33 c
61.533997 11.47 61.603996 11.64 61.603996 11.84 c
61.603996 13.32 l
61.603996 13.506666 61.533997 13.669999 61.393997 13.81 c
61.253998 13.95 61.084 14.02 60.883999 14.02 c
54.304001 14.02 l
54.304001 19.139999 l
54.304001 19.326666 54.237335 19.49 54.104 19.629999 c
53.970665 19.769999 53.804001 19.839998 53.604 19.839998 c
52.084 19.839998 l
51.897335 19.839998 51.734001 19.769999 51.593998 19.629999 c
51.453995 19.49 51.383995 19.326666 51.383999 19.139999 c
51.383999 11.839999 l
51.383999 11.639999 51.453999 11.469999 51.593998 11.329999 c
51.733997 11.189999 51.897331 11.119999 52.084 11.119999 c
60.883999 11.119999 l
60.883999 11.12 l
h
61.784 5 m
61.970665 5 62.133999 5.07 62.274002 5.21 c
62.414005 5.35 62.484005 5.513333 62.484001 5.7 c
62.484001 7.16 l
62.484001 7.36 62.414001 7.53 62.274002 7.67 c
62.134003 7.81 61.970669 7.88 61.784 7.88 c
52.084 7.88 l
51.897335 7.88 51.734001 7.81 51.593998 7.67 c
51.453995 7.53 51.383995 7.36 51.383999 7.16 c
51.383999 5.7 l
51.383999 5.513333 51.453999 5.35 51.593998 5.21 c
51.733997 5.07 51.897331 5.000001 52.084 5 c
61.784 5 l
h
79.512001 18.860001 m
79.605331 19.073334 79.582001 19.290001 79.442001 19.51 c
79.302002 19.73 79.112 19.84 78.872002 19.84 c
65.552002 19.84 l
65.431999 19.84 65.318672 19.809999 65.212006 19.75 c
65.105339 19.690001 65.025345 19.613333 64.972008 19.52 c
64.82534 19.306667 64.805344 19.086668 64.91201 18.860001 c
65.532013 17.400002 l
65.58535 17.266668 65.672012 17.160002 65.792015 17.080002 c
65.912018 17.000002 66.038681 16.960003 66.172012 16.960001 c
75.492012 16.960001 l
72.212013 9.120001 l
69.532013 15.520001 l
69.478676 15.653334 69.395348 15.756667 69.282013 15.830001 c
69.168678 15.903335 69.038681 15.940002 68.892014 15.940001 c
67.212013 15.940001 l
66.958679 15.940001 66.758682 15.833334 66.612015 15.620001 c
66.558678 15.526668 66.525345 15.420001 66.512016 15.300001 c
66.498688 15.180001 66.512016 15.066669 66.552017 14.960001 c
70.612015 5.440001 l
70.665352 5.306667 70.74868 5.200001 70.862015 5.12 c
70.975349 5.04 71.105347 5 71.252014 5.000001 c
73.172012 5.000001 l
73.31868 5.000001 73.448677 5.04 73.562012 5.12 c
73.675346 5.2 73.758675 5.306667 73.812012 5.440001 c
79.512001 18.860001 l
h
92 5.52 m
92.813332 5.826667 93.546669 6.32 94.199997 7 c
95.599998 8.413333 96.299995 10.216666 96.299995 12.41 c
96.299995 14.603334 95.599998 16.413334 94.199997 17.84 c
93.546661 18.52 92.813332 19.013334 92 19.32 c
91.159996 19.666666 90.299995 19.84 89.419998 19.84 c
83.759995 19.84 l
83.573326 19.84 83.409996 19.77 83.269997 19.630001 c
83.129997 19.490002 83.059998 19.326668 83.059998 19.140001 c
83.059998 17.700001 l
83.059998 17.500002 83.129997 17.330002 83.269997 17.190001 c
83.409996 17.049999 83.573326 16.98 83.759995 16.980001 c
89.359993 16.980001 l
90.546661 16.980001 91.509995 16.546669 92.249992 15.680001 c
92.98999 14.813335 93.359993 13.726667 93.359993 12.420001 c
93.359993 11.113335 92.98999 10.026668 92.249992 9.160001 c
91.509995 8.293334 90.546661 7.860001 89.359993 7.860001 c
83.759995 7.860001 l
83.573326 7.860001 83.409996 7.79 83.269997 7.650001 c
83.129997 7.510001 83.059998 7.340001 83.059998 7.14 c
83.059998 5.7 l
83.059998 5.513333 83.129997 5.35 83.269997 5.21 c
83.409996 5.07 83.573326 5 83.759995 5 c
89.419998 5 l
90.299995 5 91.159996 5.173333 92 5.52 c
h
f
Q
Q
Q
showpage
%%PageTrailer
pdfEndPage
%%Trailer
end
%%DocumentSuppliedResources:
%%EOF

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="3200"
height="1355.480324331485" viewBox="0 0 3200 1355.480324331485">
<g transform="scale(10) translate(10, 10)">
<defs id="SvgjsDefs1385">
<linearGradient id="SvgjsLinearGradient1390">
<stop id="SvgjsStop1391" stop-color="#905e26" offset="0"/>
<stop id="SvgjsStop1392" stop-color="#f5ec9b" offset="0.5"/>
<stop id="SvgjsStop1393" stop-color="#905e26" offset="1"/>
</linearGradient>
<linearGradient id="SvgjsLinearGradient1394">
<stop id="SvgjsStop1395" stop-color="#905e26" offset="0"/>
<stop id="SvgjsStop1396" stop-color="#f5ec9b" offset="0.5"/>
<stop id="SvgjsStop1397" stop-color="#905e26" offset="1"/>
</linearGradient>
</defs>
<g id="SvgjsG1386" featureKey="aMgJeN-0"
transform="matrix(1.5610770874511997,0,0,1.5610770874511997,71.94613967240352,-53.841545411371435)"
fill="#000">
<path xmlns="http://www.w3.org/2000/svg"
d="M29.399,57.112c0.615,0.308,1.077,0.846,1.077,1.383c0,1.039-1.038,1.961-1.999,1.462l-15.223-7.881 c-0.846-0.499-1.576-0.808-1.576-1.884c0-1.115,0.692-1.385,1.576-1.922l15.223-7.881c1.038-0.346,1.999,0.424,1.999,1.462 c0,0.575-0.461,1.114-1.077,1.423l-13.761,6.918L29.399,57.112z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M29.033,60.209c-0.208,0-0.413-0.052-0.608-0.152l-15.223-7.881c-0.086-0.05-0.165-0.095-0.242-0.141 c-0.748-0.431-1.395-0.804-1.395-1.843c0-1.057,0.594-1.406,1.346-1.849c0.093-0.054,0.187-0.11,0.284-0.169l15.229-7.885 c0.195-0.066,0.377-0.097,0.558-0.097c0.9,0,1.606,0.728,1.606,1.658c0,0.587-0.437,1.172-1.139,1.522l-13.562,6.818l13.562,6.818 c0.691,0.346,1.139,0.93,1.139,1.484C30.588,59.407,29.861,60.209,29.033,60.209z M28.982,40.419c-0.156,0-0.314,0.026-0.47,0.078 L13.306,48.37c-0.091,0.057-0.188,0.113-0.281,0.167c-0.743,0.439-1.234,0.728-1.234,1.655c0,0.91,0.537,1.219,1.281,1.648 c0.079,0.045,0.158,0.09,0.239,0.139l15.217,7.877c0.162,0.084,0.332,0.127,0.504,0.127c0.697,0,1.33-0.709,1.33-1.488 c0-0.465-0.407-0.98-1.014-1.283l-13.962-7.02l13.962-7.02c0.616-0.308,1.014-0.828,1.014-1.321 C30.363,41.048,29.756,40.419,28.982,40.419z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M46.385,64.416c-0.231,0.691-0.922,1.077-1.614,0.961c-0.769-0.153-1.269-0.885-1.154-1.692 c0-0.076,0.039-0.191,0.077-0.307l9.88-27.831c0.23-0.692,0.922-1.038,1.614-0.923c0.73,0.154,1.269,0.885,1.153,1.652 c0,0.078-0.039,0.193-0.077,0.27L46.385,64.416z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M45.016,65.511c-0.088,0-0.177-0.008-0.263-0.022c-0.837-0.167-1.371-0.949-1.247-1.819 c-0.001-0.076,0.038-0.193,0.079-0.318l9.883-27.842c0.207-0.618,0.778-1.02,1.457-1.02c0.094,0,0.188,0.009,0.281,0.023 c0.812,0.172,1.369,0.97,1.247,1.781c0.001,0.086-0.047,0.22-0.087,0.302l-9.875,27.856C46.281,65.084,45.688,65.511,45.016,65.511z M54.925,34.715c-0.58,0-1.068,0.34-1.244,0.867l-9.88,27.833c-0.035,0.104-0.07,0.213-0.07,0.27 c-0.108,0.767,0.349,1.439,1.062,1.582c0.071,0.012,0.147,0.018,0.223,0.018c0.575,0,1.083-0.363,1.263-0.904l9.881-27.872 c0.041-0.085,0.07-0.182,0.07-0.231c0.105-0.712-0.373-1.396-1.064-1.543C55.089,34.722,55.007,34.715,54.925,34.715z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M84.362,50.192L70.6,43.274c-0.614-0.309-1.075-0.848-1.075-1.423c0-1.038,1.037-1.962,1.998-1.462l15.223,7.881 c0.885,0.537,1.576,0.807,1.576,1.922c0,1.076-0.73,1.385-1.576,1.884l-15.223,7.881c-0.961,0.499-1.998-0.423-1.998-1.462 c0-0.537,0.461-1.075,1.075-1.383L84.362,50.192z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M70.967,60.209c-0.828,0-1.556-0.802-1.556-1.714c0-0.555,0.447-1.139,1.139-1.484l13.562-6.818L70.55,43.374 c-0.702-0.352-1.139-0.936-1.139-1.522c0-0.913,0.728-1.714,1.556-1.714c0.209,0,0.413,0.051,0.608,0.152l15.223,7.881 c0.104,0.062,0.198,0.119,0.29,0.173c0.753,0.442,1.347,0.792,1.347,1.849c0,1.039-0.646,1.412-1.395,1.843 c-0.078,0.046-0.157,0.091-0.237,0.138l-15.228,7.884C71.38,60.157,71.176,60.209,70.967,60.209z M70.967,40.363 c-0.696,0-1.33,0.709-1.33,1.488c0,0.493,0.397,1.013,1.014,1.321l13.962,7.02l-13.962,7.02c-0.606,0.305-1.014,0.82-1.014,1.283 c0,0.779,0.634,1.488,1.33,1.488c0.172,0,0.342-0.043,0.504-0.127l15.223-7.88c0.075-0.046,0.155-0.091,0.233-0.136 c0.744-0.43,1.282-0.738,1.282-1.648c0-0.928-0.491-1.216-1.235-1.655c-0.093-0.054-0.188-0.11-0.287-0.17l-15.216-7.876 C71.309,40.405,71.139,40.363,70.967,40.363z"/>
</g>
<g id="SvgjsG1387" featureKey="8L6ael-0"
transform="matrix(3.168568052463937,0,0,3.168568052463937,-5.1330821487142195,52.17667742743372)"
fill="#000">
<path d="M10.56 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z M32.208 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M45.535999999999994 12.42 q0.86 0.54 1.31 1.32 t0.45 1.74 q0 2.26 -1.72 3.46 q-1.16 0.84 -3.14 1.06 l-0.08 0 q-0.28 0 -0.46 -0.18 q-0.24 -0.22 -0.24 -0.52 l0 -1.44 q0 -0.26 0.18 -0.46 t0.44 -0.24 q0.94 -0.1 1.46 -0.44 q0.4 -0.24 0.54 -0.62 q0.08 -0.22 0.08 -0.56 q0 -0.22 -0.08 -0.38 t-0.28 -0.3 q-0.58 -0.4 -1.5 -0.68 l-0.32 -0.1 q-1 -0.28 -1.84 -0.46 q-0.18 -0.04 -0.58 -0.14 l-0.22 -0.06 q-0.72 -0.18 -1.52 -0.5 q-1.18 -0.5 -1.94 -1.26 q-0.88 -0.88 -0.88 -2.32 q0 -1.98 1.52 -3.22 q1.04 -0.88 2.92 -1.12 q0.32 -0.04 0.55 0.18 t0.23 0.52 l0 1.44 q0 0.26 -0.16 0.46 t-0.41 0.23 t-0.51 0.11 q-0.58 0.22 -0.82 0.43 t-0.34 0.43 q-0.1 0.34 -0.1 0.64 q0 0.16 0.18 0.34 q0.28 0.28 0.82 0.5 q0.24 0.1 0.84 0.3 l2.5 0.62 l0.12 0.04 q0.94 0.26 1.36 0.4 q0.94 0.32 1.64 0.78 z M42.196 7.9 q-0.24 -0.06 -0.39 -0.25 t-0.15 -0.45 l0 -1.46 q0 -0.32 0.26 -0.54 q0.1 -0.1 0.26 -0.13 t0.3 -0.01 q1.76 0.28 2.86 1.2 q1.5 1.24 1.6 3.16 q0.02 0.28 -0.19 0.51 t-0.51 0.23 l-1.56 0 q-0.26 0 -0.46 -0.18 t-0.22 -0.44 q-0.08 -0.52 -0.34 -0.86 q-0.42 -0.5 -1.3 -0.76 q-0.06 0 -0.08 -0.02 l-0.08 0 z M39.855999999999995 17.08 q0.24 0.04 0.4 0.24 t0.16 0.44 l0 1.48 q0 0.32 -0.24 0.54 q-0.2 0.16 -0.46 0.16 l-0.1 0 q-1.86 -0.28 -3.06 -1.22 q-1.56 -1.24 -1.76 -3.46 q-0.04 -0.32 0.18 -0.55 t0.52 -0.23 l1.58 0 q0.28 0 0.48 0.19 t0.22 0.47 q0.06 1 0.98 1.54 q0.42 0.24 1.1 0.4 z M60.88399999999999 11.12 q0.3 0 0.51 0.21 t0.21 0.51 l0 1.48 q0 0.28 -0.21 0.49 t-0.51 0.21 l-6.58 0 l0 5.12 q0 0.28 -0.2 0.49 t-0.5 0.21 l-1.52 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -7.3 q0 -0.3 0.21 -0.51 t0.49 -0.21 l8.8 0 z M61.78399999999999 5 q0.28 0 0.49 0.21 t0.21 0.49 l0 1.46 q0 0.3 -0.21 0.51 t-0.49 0.21 l-9.7 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.46 q0 -0.28 0.21 -0.49 t0.49 -0.21 l9.7 0 z M79.512 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M92 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="3200"
height="1355.480324331485" viewBox="0 0 3200 1355.480324331485">
<g transform="scale(10) translate(10, 10)">
<defs id="SvgjsDefs1385">
<linearGradient id="SvgjsLinearGradient1390">
<stop id="SvgjsStop1391" stop-color="#905e26" offset="0"></stop>
<stop id="SvgjsStop1392" stop-color="#f5ec9b" offset="0.5"></stop>
<stop id="SvgjsStop1393" stop-color="#905e26" offset="1"></stop>
</linearGradient>
<linearGradient id="SvgjsLinearGradient1394">
<stop id="SvgjsStop1395" stop-color="#905e26" offset="0"></stop>
<stop id="SvgjsStop1396" stop-color="#f5ec9b" offset="0.5"></stop>
<stop id="SvgjsStop1397" stop-color="#905e26" offset="1"></stop>
</linearGradient>
</defs>
<g id="SvgjsG1386" featureKey="aMgJeN-0"
transform="matrix(1.5610770874511997,0,0,1.5610770874511997,71.94613967240352,-53.841545411371435)"
fill="url(#SvgjsLinearGradient1390)">
<path xmlns="http://www.w3.org/2000/svg"
d="M29.399,57.112c0.615,0.308,1.077,0.846,1.077,1.383c0,1.039-1.038,1.961-1.999,1.462l-15.223-7.881 c-0.846-0.499-1.576-0.808-1.576-1.884c0-1.115,0.692-1.385,1.576-1.922l15.223-7.881c1.038-0.346,1.999,0.424,1.999,1.462 c0,0.575-0.461,1.114-1.077,1.423l-13.761,6.918L29.399,57.112z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M29.033,60.209c-0.208,0-0.413-0.052-0.608-0.152l-15.223-7.881c-0.086-0.05-0.165-0.095-0.242-0.141 c-0.748-0.431-1.395-0.804-1.395-1.843c0-1.057,0.594-1.406,1.346-1.849c0.093-0.054,0.187-0.11,0.284-0.169l15.229-7.885 c0.195-0.066,0.377-0.097,0.558-0.097c0.9,0,1.606,0.728,1.606,1.658c0,0.587-0.437,1.172-1.139,1.522l-13.562,6.818l13.562,6.818 c0.691,0.346,1.139,0.93,1.139,1.484C30.588,59.407,29.861,60.209,29.033,60.209z M28.982,40.419c-0.156,0-0.314,0.026-0.47,0.078 L13.306,48.37c-0.091,0.057-0.188,0.113-0.281,0.167c-0.743,0.439-1.234,0.728-1.234,1.655c0,0.91,0.537,1.219,1.281,1.648 c0.079,0.045,0.158,0.09,0.239,0.139l15.217,7.877c0.162,0.084,0.332,0.127,0.504,0.127c0.697,0,1.33-0.709,1.33-1.488 c0-0.465-0.407-0.98-1.014-1.283l-13.962-7.02l13.962-7.02c0.616-0.308,1.014-0.828,1.014-1.321 C30.363,41.048,29.756,40.419,28.982,40.419z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M46.385,64.416c-0.231,0.691-0.922,1.077-1.614,0.961c-0.769-0.153-1.269-0.885-1.154-1.692 c0-0.076,0.039-0.191,0.077-0.307l9.88-27.831c0.23-0.692,0.922-1.038,1.614-0.923c0.73,0.154,1.269,0.885,1.153,1.652 c0,0.078-0.039,0.193-0.077,0.27L46.385,64.416z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M45.016,65.511c-0.088,0-0.177-0.008-0.263-0.022c-0.837-0.167-1.371-0.949-1.247-1.819 c-0.001-0.076,0.038-0.193,0.079-0.318l9.883-27.842c0.207-0.618,0.778-1.02,1.457-1.02c0.094,0,0.188,0.009,0.281,0.023 c0.812,0.172,1.369,0.97,1.247,1.781c0.001,0.086-0.047,0.22-0.087,0.302l-9.875,27.856C46.281,65.084,45.688,65.511,45.016,65.511z M54.925,34.715c-0.58,0-1.068,0.34-1.244,0.867l-9.88,27.833c-0.035,0.104-0.07,0.213-0.07,0.27 c-0.108,0.767,0.349,1.439,1.062,1.582c0.071,0.012,0.147,0.018,0.223,0.018c0.575,0,1.083-0.363,1.263-0.904l9.881-27.872 c0.041-0.085,0.07-0.182,0.07-0.231c0.105-0.712-0.373-1.396-1.064-1.543C55.089,34.722,55.007,34.715,54.925,34.715z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M84.362,50.192L70.6,43.274c-0.614-0.309-1.075-0.848-1.075-1.423c0-1.038,1.037-1.962,1.998-1.462l15.223,7.881 c0.885,0.537,1.576,0.807,1.576,1.922c0,1.076-0.73,1.385-1.576,1.884l-15.223,7.881c-0.961,0.499-1.998-0.423-1.998-1.462 c0-0.537,0.461-1.075,1.075-1.383L84.362,50.192z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M70.967,60.209c-0.828,0-1.556-0.802-1.556-1.714c0-0.555,0.447-1.139,1.139-1.484l13.562-6.818L70.55,43.374 c-0.702-0.352-1.139-0.936-1.139-1.522c0-0.913,0.728-1.714,1.556-1.714c0.209,0,0.413,0.051,0.608,0.152l15.223,7.881 c0.104,0.062,0.198,0.119,0.29,0.173c0.753,0.442,1.347,0.792,1.347,1.849c0,1.039-0.646,1.412-1.395,1.843 c-0.078,0.046-0.157,0.091-0.237,0.138l-15.228,7.884C71.38,60.157,71.176,60.209,70.967,60.209z M70.967,40.363 c-0.696,0-1.33,0.709-1.33,1.488c0,0.493,0.397,1.013,1.014,1.321l13.962,7.02l-13.962,7.02c-0.606,0.305-1.014,0.82-1.014,1.283 c0,0.779,0.634,1.488,1.33,1.488c0.172,0,0.342-0.043,0.504-0.127l15.223-7.88c0.075-0.046,0.155-0.091,0.233-0.136 c0.744-0.43,1.282-0.738,1.282-1.648c0-0.928-0.491-1.216-1.235-1.655c-0.093-0.054-0.188-0.11-0.287-0.17l-15.216-7.876 C71.309,40.405,71.139,40.363,70.967,40.363z"></path>
</g>
<g id="SvgjsG1387" featureKey="8L6ael-0"
transform="matrix(3.168568052463937,0,0,3.168568052463937,-5.1330821487142195,52.17667742743372)"
fill="url(#SvgjsLinearGradient1394)">
<path d="M10.56 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z M32.208 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M45.535999999999994 12.42 q0.86 0.54 1.31 1.32 t0.45 1.74 q0 2.26 -1.72 3.46 q-1.16 0.84 -3.14 1.06 l-0.08 0 q-0.28 0 -0.46 -0.18 q-0.24 -0.22 -0.24 -0.52 l0 -1.44 q0 -0.26 0.18 -0.46 t0.44 -0.24 q0.94 -0.1 1.46 -0.44 q0.4 -0.24 0.54 -0.62 q0.08 -0.22 0.08 -0.56 q0 -0.22 -0.08 -0.38 t-0.28 -0.3 q-0.58 -0.4 -1.5 -0.68 l-0.32 -0.1 q-1 -0.28 -1.84 -0.46 q-0.18 -0.04 -0.58 -0.14 l-0.22 -0.06 q-0.72 -0.18 -1.52 -0.5 q-1.18 -0.5 -1.94 -1.26 q-0.88 -0.88 -0.88 -2.32 q0 -1.98 1.52 -3.22 q1.04 -0.88 2.92 -1.12 q0.32 -0.04 0.55 0.18 t0.23 0.52 l0 1.44 q0 0.26 -0.16 0.46 t-0.41 0.23 t-0.51 0.11 q-0.58 0.22 -0.82 0.43 t-0.34 0.43 q-0.1 0.34 -0.1 0.64 q0 0.16 0.18 0.34 q0.28 0.28 0.82 0.5 q0.24 0.1 0.84 0.3 l2.5 0.62 l0.12 0.04 q0.94 0.26 1.36 0.4 q0.94 0.32 1.64 0.78 z M42.196 7.9 q-0.24 -0.06 -0.39 -0.25 t-0.15 -0.45 l0 -1.46 q0 -0.32 0.26 -0.54 q0.1 -0.1 0.26 -0.13 t0.3 -0.01 q1.76 0.28 2.86 1.2 q1.5 1.24 1.6 3.16 q0.02 0.28 -0.19 0.51 t-0.51 0.23 l-1.56 0 q-0.26 0 -0.46 -0.18 t-0.22 -0.44 q-0.08 -0.52 -0.34 -0.86 q-0.42 -0.5 -1.3 -0.76 q-0.06 0 -0.08 -0.02 l-0.08 0 z M39.855999999999995 17.08 q0.24 0.04 0.4 0.24 t0.16 0.44 l0 1.48 q0 0.32 -0.24 0.54 q-0.2 0.16 -0.46 0.16 l-0.1 0 q-1.86 -0.28 -3.06 -1.22 q-1.56 -1.24 -1.76 -3.46 q-0.04 -0.32 0.18 -0.55 t0.52 -0.23 l1.58 0 q0.28 0 0.48 0.19 t0.22 0.47 q0.06 1 0.98 1.54 q0.42 0.24 1.1 0.4 z M60.88399999999999 11.12 q0.3 0 0.51 0.21 t0.21 0.51 l0 1.48 q0 0.28 -0.21 0.49 t-0.51 0.21 l-6.58 0 l0 5.12 q0 0.28 -0.2 0.49 t-0.5 0.21 l-1.52 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -7.3 q0 -0.3 0.21 -0.51 t0.49 -0.21 l8.8 0 z M61.78399999999999 5 q0.28 0 0.49 0.21 t0.21 0.49 l0 1.46 q0 0.3 -0.21 0.51 t-0.49 0.21 l-9.7 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.46 q0 -0.28 0.21 -0.49 t0.49 -0.21 l9.7 0 z M79.512 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M92 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="3200"
height="1355.480324331485" viewBox="0 0 3200 1355.480324331485">
<rect fill="#292929" width="3200" height="1355.480324331485"/>
<g transform="scale(10) translate(10, 10)">
<defs id="SvgjsDefs1385">
<linearGradient id="SvgjsLinearGradient1390">
<stop id="SvgjsStop1391" stop-color="#905e26" offset="0"></stop>
<stop id="SvgjsStop1392" stop-color="#f5ec9b" offset="0.5"></stop>
<stop id="SvgjsStop1393" stop-color="#905e26" offset="1"></stop>
</linearGradient>
<linearGradient id="SvgjsLinearGradient1394">
<stop id="SvgjsStop1395" stop-color="#905e26" offset="0"></stop>
<stop id="SvgjsStop1396" stop-color="#f5ec9b" offset="0.5"></stop>
<stop id="SvgjsStop1397" stop-color="#905e26" offset="1"></stop>
</linearGradient>
</defs>
<g id="SvgjsG1386" featureKey="aMgJeN-0"
transform="matrix(1.5610770874511997,0,0,1.5610770874511997,71.94613967240352,-53.841545411371435)"
fill="url(#SvgjsLinearGradient1390)">
<path xmlns="http://www.w3.org/2000/svg"
d="M29.399,57.112c0.615,0.308,1.077,0.846,1.077,1.383c0,1.039-1.038,1.961-1.999,1.462l-15.223-7.881 c-0.846-0.499-1.576-0.808-1.576-1.884c0-1.115,0.692-1.385,1.576-1.922l15.223-7.881c1.038-0.346,1.999,0.424,1.999,1.462 c0,0.575-0.461,1.114-1.077,1.423l-13.761,6.918L29.399,57.112z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M29.033,60.209c-0.208,0-0.413-0.052-0.608-0.152l-15.223-7.881c-0.086-0.05-0.165-0.095-0.242-0.141 c-0.748-0.431-1.395-0.804-1.395-1.843c0-1.057,0.594-1.406,1.346-1.849c0.093-0.054,0.187-0.11,0.284-0.169l15.229-7.885 c0.195-0.066,0.377-0.097,0.558-0.097c0.9,0,1.606,0.728,1.606,1.658c0,0.587-0.437,1.172-1.139,1.522l-13.562,6.818l13.562,6.818 c0.691,0.346,1.139,0.93,1.139,1.484C30.588,59.407,29.861,60.209,29.033,60.209z M28.982,40.419c-0.156,0-0.314,0.026-0.47,0.078 L13.306,48.37c-0.091,0.057-0.188,0.113-0.281,0.167c-0.743,0.439-1.234,0.728-1.234,1.655c0,0.91,0.537,1.219,1.281,1.648 c0.079,0.045,0.158,0.09,0.239,0.139l15.217,7.877c0.162,0.084,0.332,0.127,0.504,0.127c0.697,0,1.33-0.709,1.33-1.488 c0-0.465-0.407-0.98-1.014-1.283l-13.962-7.02l13.962-7.02c0.616-0.308,1.014-0.828,1.014-1.321 C30.363,41.048,29.756,40.419,28.982,40.419z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M46.385,64.416c-0.231,0.691-0.922,1.077-1.614,0.961c-0.769-0.153-1.269-0.885-1.154-1.692 c0-0.076,0.039-0.191,0.077-0.307l9.88-27.831c0.23-0.692,0.922-1.038,1.614-0.923c0.73,0.154,1.269,0.885,1.153,1.652 c0,0.078-0.039,0.193-0.077,0.27L46.385,64.416z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M45.016,65.511c-0.088,0-0.177-0.008-0.263-0.022c-0.837-0.167-1.371-0.949-1.247-1.819 c-0.001-0.076,0.038-0.193,0.079-0.318l9.883-27.842c0.207-0.618,0.778-1.02,1.457-1.02c0.094,0,0.188,0.009,0.281,0.023 c0.812,0.172,1.369,0.97,1.247,1.781c0.001,0.086-0.047,0.22-0.087,0.302l-9.875,27.856C46.281,65.084,45.688,65.511,45.016,65.511z M54.925,34.715c-0.58,0-1.068,0.34-1.244,0.867l-9.88,27.833c-0.035,0.104-0.07,0.213-0.07,0.27 c-0.108,0.767,0.349,1.439,1.062,1.582c0.071,0.012,0.147,0.018,0.223,0.018c0.575,0,1.083-0.363,1.263-0.904l9.881-27.872 c0.041-0.085,0.07-0.182,0.07-0.231c0.105-0.712-0.373-1.396-1.064-1.543C55.089,34.722,55.007,34.715,54.925,34.715z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M84.362,50.192L70.6,43.274c-0.614-0.309-1.075-0.848-1.075-1.423c0-1.038,1.037-1.962,1.998-1.462l15.223,7.881 c0.885,0.537,1.576,0.807,1.576,1.922c0,1.076-0.73,1.385-1.576,1.884l-15.223,7.881c-0.961,0.499-1.998-0.423-1.998-1.462 c0-0.537,0.461-1.075,1.075-1.383L84.362,50.192z"></path>
<path xmlns="http://www.w3.org/2000/svg"
d="M70.967,60.209c-0.828,0-1.556-0.802-1.556-1.714c0-0.555,0.447-1.139,1.139-1.484l13.562-6.818L70.55,43.374 c-0.702-0.352-1.139-0.936-1.139-1.522c0-0.913,0.728-1.714,1.556-1.714c0.209,0,0.413,0.051,0.608,0.152l15.223,7.881 c0.104,0.062,0.198,0.119,0.29,0.173c0.753,0.442,1.347,0.792,1.347,1.849c0,1.039-0.646,1.412-1.395,1.843 c-0.078,0.046-0.157,0.091-0.237,0.138l-15.228,7.884C71.38,60.157,71.176,60.209,70.967,60.209z M70.967,40.363 c-0.696,0-1.33,0.709-1.33,1.488c0,0.493,0.397,1.013,1.014,1.321l13.962,7.02l-13.962,7.02c-0.606,0.305-1.014,0.82-1.014,1.283 c0,0.779,0.634,1.488,1.33,1.488c0.172,0,0.342-0.043,0.504-0.127l15.223-7.88c0.075-0.046,0.155-0.091,0.233-0.136 c0.744-0.43,1.282-0.738,1.282-1.648c0-0.928-0.491-1.216-1.235-1.655c-0.093-0.054-0.188-0.11-0.287-0.17l-15.216-7.876 C71.309,40.405,71.139,40.363,70.967,40.363z"></path>
</g>
<g id="SvgjsG1387" featureKey="8L6ael-0"
transform="matrix(3.168568052463937,0,0,3.168568052463937,-5.1330821487142195,52.17667742743372)"
fill="url(#SvgjsLinearGradient1394)">
<path d="M10.56 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z M32.208 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M45.535999999999994 12.42 q0.86 0.54 1.31 1.32 t0.45 1.74 q0 2.26 -1.72 3.46 q-1.16 0.84 -3.14 1.06 l-0.08 0 q-0.28 0 -0.46 -0.18 q-0.24 -0.22 -0.24 -0.52 l0 -1.44 q0 -0.26 0.18 -0.46 t0.44 -0.24 q0.94 -0.1 1.46 -0.44 q0.4 -0.24 0.54 -0.62 q0.08 -0.22 0.08 -0.56 q0 -0.22 -0.08 -0.38 t-0.28 -0.3 q-0.58 -0.4 -1.5 -0.68 l-0.32 -0.1 q-1 -0.28 -1.84 -0.46 q-0.18 -0.04 -0.58 -0.14 l-0.22 -0.06 q-0.72 -0.18 -1.52 -0.5 q-1.18 -0.5 -1.94 -1.26 q-0.88 -0.88 -0.88 -2.32 q0 -1.98 1.52 -3.22 q1.04 -0.88 2.92 -1.12 q0.32 -0.04 0.55 0.18 t0.23 0.52 l0 1.44 q0 0.26 -0.16 0.46 t-0.41 0.23 t-0.51 0.11 q-0.58 0.22 -0.82 0.43 t-0.34 0.43 q-0.1 0.34 -0.1 0.64 q0 0.16 0.18 0.34 q0.28 0.28 0.82 0.5 q0.24 0.1 0.84 0.3 l2.5 0.62 l0.12 0.04 q0.94 0.26 1.36 0.4 q0.94 0.32 1.64 0.78 z M42.196 7.9 q-0.24 -0.06 -0.39 -0.25 t-0.15 -0.45 l0 -1.46 q0 -0.32 0.26 -0.54 q0.1 -0.1 0.26 -0.13 t0.3 -0.01 q1.76 0.28 2.86 1.2 q1.5 1.24 1.6 3.16 q0.02 0.28 -0.19 0.51 t-0.51 0.23 l-1.56 0 q-0.26 0 -0.46 -0.18 t-0.22 -0.44 q-0.08 -0.52 -0.34 -0.86 q-0.42 -0.5 -1.3 -0.76 q-0.06 0 -0.08 -0.02 l-0.08 0 z M39.855999999999995 17.08 q0.24 0.04 0.4 0.24 t0.16 0.44 l0 1.48 q0 0.32 -0.24 0.54 q-0.2 0.16 -0.46 0.16 l-0.1 0 q-1.86 -0.28 -3.06 -1.22 q-1.56 -1.24 -1.76 -3.46 q-0.04 -0.32 0.18 -0.55 t0.52 -0.23 l1.58 0 q0.28 0 0.48 0.19 t0.22 0.47 q0.06 1 0.98 1.54 q0.42 0.24 1.1 0.4 z M60.88399999999999 11.12 q0.3 0 0.51 0.21 t0.21 0.51 l0 1.48 q0 0.28 -0.21 0.49 t-0.51 0.21 l-6.58 0 l0 5.12 q0 0.28 -0.2 0.49 t-0.5 0.21 l-1.52 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -7.3 q0 -0.3 0.21 -0.51 t0.49 -0.21 l8.8 0 z M61.78399999999999 5 q0.28 0 0.49 0.21 t0.21 0.49 l0 1.46 q0 0.3 -0.21 0.51 t-0.49 0.21 l-9.7 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.46 q0 -0.28 0.21 -0.49 t0.49 -0.21 l9.7 0 z M79.512 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M92 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="3200"
height="1355.480324331485" viewBox="0 0 3200 1355.480324331485">
<g transform="scale(10) translate(10, 10)">
<defs id="SvgjsDefs1385">
<linearGradient id="SvgjsLinearGradient1390">
<stop id="SvgjsStop1391" stop-color="#905e26" offset="0"/>
<stop id="SvgjsStop1392" stop-color="#f5ec9b" offset="0.5"/>
<stop id="SvgjsStop1393" stop-color="#905e26" offset="1"/>
</linearGradient>
<linearGradient id="SvgjsLinearGradient1394">
<stop id="SvgjsStop1395" stop-color="#905e26" offset="0"/>
<stop id="SvgjsStop1396" stop-color="#f5ec9b" offset="0.5"/>
<stop id="SvgjsStop1397" stop-color="#905e26" offset="1"/>
</linearGradient>
</defs>
<g id="SvgjsG1386" featureKey="aMgJeN-0"
transform="matrix(1.5610770874511997,0,0,1.5610770874511997,71.94613967240352,-53.841545411371435)"
fill="#fff">
<path xmlns="http://www.w3.org/2000/svg"
d="M29.399,57.112c0.615,0.308,1.077,0.846,1.077,1.383c0,1.039-1.038,1.961-1.999,1.462l-15.223-7.881 c-0.846-0.499-1.576-0.808-1.576-1.884c0-1.115,0.692-1.385,1.576-1.922l15.223-7.881c1.038-0.346,1.999,0.424,1.999,1.462 c0,0.575-0.461,1.114-1.077,1.423l-13.761,6.918L29.399,57.112z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M29.033,60.209c-0.208,0-0.413-0.052-0.608-0.152l-15.223-7.881c-0.086-0.05-0.165-0.095-0.242-0.141 c-0.748-0.431-1.395-0.804-1.395-1.843c0-1.057,0.594-1.406,1.346-1.849c0.093-0.054,0.187-0.11,0.284-0.169l15.229-7.885 c0.195-0.066,0.377-0.097,0.558-0.097c0.9,0,1.606,0.728,1.606,1.658c0,0.587-0.437,1.172-1.139,1.522l-13.562,6.818l13.562,6.818 c0.691,0.346,1.139,0.93,1.139,1.484C30.588,59.407,29.861,60.209,29.033,60.209z M28.982,40.419c-0.156,0-0.314,0.026-0.47,0.078 L13.306,48.37c-0.091,0.057-0.188,0.113-0.281,0.167c-0.743,0.439-1.234,0.728-1.234,1.655c0,0.91,0.537,1.219,1.281,1.648 c0.079,0.045,0.158,0.09,0.239,0.139l15.217,7.877c0.162,0.084,0.332,0.127,0.504,0.127c0.697,0,1.33-0.709,1.33-1.488 c0-0.465-0.407-0.98-1.014-1.283l-13.962-7.02l13.962-7.02c0.616-0.308,1.014-0.828,1.014-1.321 C30.363,41.048,29.756,40.419,28.982,40.419z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M46.385,64.416c-0.231,0.691-0.922,1.077-1.614,0.961c-0.769-0.153-1.269-0.885-1.154-1.692 c0-0.076,0.039-0.191,0.077-0.307l9.88-27.831c0.23-0.692,0.922-1.038,1.614-0.923c0.73,0.154,1.269,0.885,1.153,1.652 c0,0.078-0.039,0.193-0.077,0.27L46.385,64.416z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M45.016,65.511c-0.088,0-0.177-0.008-0.263-0.022c-0.837-0.167-1.371-0.949-1.247-1.819 c-0.001-0.076,0.038-0.193,0.079-0.318l9.883-27.842c0.207-0.618,0.778-1.02,1.457-1.02c0.094,0,0.188,0.009,0.281,0.023 c0.812,0.172,1.369,0.97,1.247,1.781c0.001,0.086-0.047,0.22-0.087,0.302l-9.875,27.856C46.281,65.084,45.688,65.511,45.016,65.511z M54.925,34.715c-0.58,0-1.068,0.34-1.244,0.867l-9.88,27.833c-0.035,0.104-0.07,0.213-0.07,0.27 c-0.108,0.767,0.349,1.439,1.062,1.582c0.071,0.012,0.147,0.018,0.223,0.018c0.575,0,1.083-0.363,1.263-0.904l9.881-27.872 c0.041-0.085,0.07-0.182,0.07-0.231c0.105-0.712-0.373-1.396-1.064-1.543C55.089,34.722,55.007,34.715,54.925,34.715z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M84.362,50.192L70.6,43.274c-0.614-0.309-1.075-0.848-1.075-1.423c0-1.038,1.037-1.962,1.998-1.462l15.223,7.881 c0.885,0.537,1.576,0.807,1.576,1.922c0,1.076-0.73,1.385-1.576,1.884l-15.223,7.881c-0.961,0.499-1.998-0.423-1.998-1.462 c0-0.537,0.461-1.075,1.075-1.383L84.362,50.192z"/>
<path xmlns="http://www.w3.org/2000/svg"
d="M70.967,60.209c-0.828,0-1.556-0.802-1.556-1.714c0-0.555,0.447-1.139,1.139-1.484l13.562-6.818L70.55,43.374 c-0.702-0.352-1.139-0.936-1.139-1.522c0-0.913,0.728-1.714,1.556-1.714c0.209,0,0.413,0.051,0.608,0.152l15.223,7.881 c0.104,0.062,0.198,0.119,0.29,0.173c0.753,0.442,1.347,0.792,1.347,1.849c0,1.039-0.646,1.412-1.395,1.843 c-0.078,0.046-0.157,0.091-0.237,0.138l-15.228,7.884C71.38,60.157,71.176,60.209,70.967,60.209z M70.967,40.363 c-0.696,0-1.33,0.709-1.33,1.488c0,0.493,0.397,1.013,1.014,1.321l13.962,7.02l-13.962,7.02c-0.606,0.305-1.014,0.82-1.014,1.283 c0,0.779,0.634,1.488,1.33,1.488c0.172,0,0.342-0.043,0.504-0.127l15.223-7.88c0.075-0.046,0.155-0.091,0.233-0.136 c0.744-0.43,1.282-0.738,1.282-1.648c0-0.928-0.491-1.216-1.235-1.655c-0.093-0.054-0.188-0.11-0.287-0.17l-15.216-7.876 C71.309,40.405,71.139,40.363,70.967,40.363z"/>
</g>
<g id="SvgjsG1387" featureKey="8L6ael-0"
transform="matrix(3.168568052463937,0,0,3.168568052463937,-5.1330821487142195,52.17667742743372)"
fill="#fff">
<path d="M10.56 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z M32.208 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M45.535999999999994 12.42 q0.86 0.54 1.31 1.32 t0.45 1.74 q0 2.26 -1.72 3.46 q-1.16 0.84 -3.14 1.06 l-0.08 0 q-0.28 0 -0.46 -0.18 q-0.24 -0.22 -0.24 -0.52 l0 -1.44 q0 -0.26 0.18 -0.46 t0.44 -0.24 q0.94 -0.1 1.46 -0.44 q0.4 -0.24 0.54 -0.62 q0.08 -0.22 0.08 -0.56 q0 -0.22 -0.08 -0.38 t-0.28 -0.3 q-0.58 -0.4 -1.5 -0.68 l-0.32 -0.1 q-1 -0.28 -1.84 -0.46 q-0.18 -0.04 -0.58 -0.14 l-0.22 -0.06 q-0.72 -0.18 -1.52 -0.5 q-1.18 -0.5 -1.94 -1.26 q-0.88 -0.88 -0.88 -2.32 q0 -1.98 1.52 -3.22 q1.04 -0.88 2.92 -1.12 q0.32 -0.04 0.55 0.18 t0.23 0.52 l0 1.44 q0 0.26 -0.16 0.46 t-0.41 0.23 t-0.51 0.11 q-0.58 0.22 -0.82 0.43 t-0.34 0.43 q-0.1 0.34 -0.1 0.64 q0 0.16 0.18 0.34 q0.28 0.28 0.82 0.5 q0.24 0.1 0.84 0.3 l2.5 0.62 l0.12 0.04 q0.94 0.26 1.36 0.4 q0.94 0.32 1.64 0.78 z M42.196 7.9 q-0.24 -0.06 -0.39 -0.25 t-0.15 -0.45 l0 -1.46 q0 -0.32 0.26 -0.54 q0.1 -0.1 0.26 -0.13 t0.3 -0.01 q1.76 0.28 2.86 1.2 q1.5 1.24 1.6 3.16 q0.02 0.28 -0.19 0.51 t-0.51 0.23 l-1.56 0 q-0.26 0 -0.46 -0.18 t-0.22 -0.44 q-0.08 -0.52 -0.34 -0.86 q-0.42 -0.5 -1.3 -0.76 q-0.06 0 -0.08 -0.02 l-0.08 0 z M39.855999999999995 17.08 q0.24 0.04 0.4 0.24 t0.16 0.44 l0 1.48 q0 0.32 -0.24 0.54 q-0.2 0.16 -0.46 0.16 l-0.1 0 q-1.86 -0.28 -3.06 -1.22 q-1.56 -1.24 -1.76 -3.46 q-0.04 -0.32 0.18 -0.55 t0.52 -0.23 l1.58 0 q0.28 0 0.48 0.19 t0.22 0.47 q0.06 1 0.98 1.54 q0.42 0.24 1.1 0.4 z M60.88399999999999 11.12 q0.3 0 0.51 0.21 t0.21 0.51 l0 1.48 q0 0.28 -0.21 0.49 t-0.51 0.21 l-6.58 0 l0 5.12 q0 0.28 -0.2 0.49 t-0.5 0.21 l-1.52 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -7.3 q0 -0.3 0.21 -0.51 t0.49 -0.21 l8.8 0 z M61.78399999999999 5 q0.28 0 0.49 0.21 t0.21 0.49 l0 1.46 q0 0.3 -0.21 0.51 t-0.49 0.21 l-9.7 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.46 q0 -0.28 0.21 -0.49 t0.49 -0.21 l9.7 0 z M79.512 18.86 q0.14 0.32 -0.07 0.65 t-0.57 0.33 l-13.32 0 q-0.18 0 -0.34 -0.09 t-0.24 -0.23 q-0.22 -0.32 -0.06 -0.66 l0.62 -1.46 q0.08 -0.2 0.26 -0.32 t0.38 -0.12 l9.32 0 l-3.28 -7.84 l-2.68 6.4 q-0.08 0.2 -0.25 0.31 t-0.39 0.11 l-1.68 0 q-0.38 0 -0.6 -0.32 q-0.08 -0.14 -0.1 -0.32 t0.04 -0.34 l4.06 -9.52 q0.08 -0.2 0.25 -0.32 t0.39 -0.12 l1.92 0 q0.22 0 0.39 0.12 t0.25 0.32 z M92 5.52 q1.22 0.46 2.2 1.48 q2.1 2.12 2.1 5.41 t-2.1 5.43 q-0.98 1.02 -2.2 1.48 q-1.26 0.52 -2.58 0.52 l-5.66 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.49 l0 -1.44 q0 -0.3 0.21 -0.51 t0.49 -0.21 l5.6 0 q1.78 0 2.89 -1.3 t1.11 -3.26 t-1.11 -3.26 t-2.89 -1.3 l-5.6 0 q-0.28 0 -0.49 -0.21 t-0.21 -0.51 l0 -1.44 q0 -0.28 0.21 -0.49 t0.49 -0.21 l5.66 0 q1.32 0 2.58 0.52 z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

3
public/b2b2c.csv Normal file
View File

@ -0,0 +1,3 @@
"ID","NAME","POSITION-X","POSITION-Y","POSITION-Z", "ROTATION-X","ROTATION-Y","ROTATION-Z", "SCALE-X","SCALE-Y","SCALE-Z"
"id2533e7b6-118a-46d0-bad9-11e73462798b","Commerce",1,0,0,0,0,0,.1,.1,.1,
,Platform,1.3,0,0,0,0,0,.1,.1,.1,
Can't render this file because it has a wrong number of fields in line 2.

View File

@ -1,5 +1,5 @@
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.1.0/workbox-sw.js');
const VERSION = '@@VERSION';
const VERSION = '0.0.8-19';
const CACHE = "deepdiagram";
const IMAGEDELIVERY_CACHE = "deepdiagram-images";
const MAPTILE_CACHE = 'maptiler';
@ -66,32 +66,35 @@ workbox.routing.registerRoute(
})
);
workbox.routing.registerRoute(
/*workbox.routing.registerRoute(
new RegExp('/assets/.*'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: CACHE
})
);
*/
workbox.routing.registerRoute(
/*workbox.routing.registerRoute(
new RegExp('/db/.*'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: CACHE
})
);
*/
workbox.routing.registerRoute(
new RegExp('/.*\\.glb'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: CACHE
})
);
/*
workbox.routing.registerRoute(
new RegExp('/.*\\.css'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: CACHE
})
);
*/

245
public/templates/demo.json Normal file
View File

@ -0,0 +1,245 @@
{
"name": "demo",
"dbName": "demo",
"exportDate": "2025-11-20T14:32:58.031Z",
"version": "1.0",
"entities": [
{
"id": "id0c8fd8ad-7dc8-41fa-b61f-b22c2d2b3eb8",
"position": {
"x": 0.4000000059604645,
"y": 1,
"z": 2.9000000953674316
},
"rotation": {
"x": 0,
"y": 3.141592653589793,
"z": 0
},
"last_seen": "2025-11-20T14:30:54.865Z",
"template": "#cylinder-template",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#FF00FF",
"text": "db",
"_id": "id0c8fd8ad-7dc8-41fa-b61f-b22c2d2b3eb8"
},
{
"from": "ide476ec05-9aac-42c9-87f1-ba7f18141767",
"to": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id18bf9938-a0b7-4e65-bf91-38697064698a",
"_id": "id18bf9938-a0b7-4e65-bf91-38697064698a"
},
{
"from": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"to": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id47bf19b7-6263-44fa-b289-1f9f63aa6aff",
"_id": "id47bf19b7-6263-44fa-b289-1f9f63aa6aff"
},
{
"from": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"to": "idac543949-c285-4f4b-ab07-d6fd2bbf7bb5",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id50594820-ace6-44c7-be5c-2b0747549c75",
"_id": "id50594820-ace6-44c7-be5c-2b0747549c75"
},
{
"from": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"to": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id562bf787-0d11-413c-bc2d-194bf05275fd",
"_id": "id562bf787-0d11-413c-bc2d-194bf05275fd"
},
{
"from": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"to": "id0c8fd8ad-7dc8-41fa-b61f-b22c2d2b3eb8",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id5b622c06-95f1-4d04-b023-b1a6851d2107",
"_id": "id5b622c06-95f1-4d04-b023-b1a6851d2107"
},
{
"id": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"position": {
"x": 0.4000000059604645,
"y": 1.7000000476837158,
"z": 2.9000000953674316
},
"rotation": {
"x": 0,
"y": 3.141592653589793,
"z": 0
},
"last_seen": "2025-11-20T14:28:52.061Z",
"template": "#sphere-template",
"text": "browser",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#8B4513",
"_id": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9"
},
{
"from": "idac543949-c285-4f4b-ab07-d6fd2bbf7bb5",
"to": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id6f4208a8-9b17-45a8-b030-83a80d7e09bf",
"_id": "id6f4208a8-9b17-45a8-b030-83a80d7e09bf"
},
{
"from": "idf1cf90c7-cc2f-4ccc-9bd6-274752f5b66f",
"to": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id7205d022-db34-4705-8e3b-930ae0351376",
"_id": "id7205d022-db34-4705-8e3b-930ae0351376"
},
{
"from": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"to": "ide476ec05-9aac-42c9-87f1-ba7f18141767",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id74b9b638-8148-4d98-a715-981f7aebd3bb",
"_id": "id74b9b638-8148-4d98-a715-981f7aebd3bb"
},
{
"from": "id0c8fd8ad-7dc8-41fa-b61f-b22c2d2b3eb8",
"to": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id7af34b2d-f790-45c9-9fce-6c627de1410e",
"_id": "id7af34b2d-f790-45c9-9fce-6c627de1410e"
},
{
"from": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"to": "idf1cf90c7-cc2f-4ccc-9bd6-274752f5b66f",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id8bdd38de-6ac9-44c1-95e9-015ed85c0b7a",
"_id": "id8bdd38de-6ac9-44c1-95e9-015ed85c0b7a"
},
{
"id": "idac543949-c285-4f4b-ab07-d6fd2bbf7bb5",
"position": {
"x": -0.6000000238418579,
"y": 1.7000000476837158,
"z": 2.799999952316284
},
"rotation": {
"x": -2.4492937051703357e-16,
"y": 3.141592653589793,
"z": -2.4492937051703357e-16
},
"last_seen": "2025-11-20T14:32:19.261Z",
"template": "#box-template",
"text": "api",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#0000FF",
"_id": "idac543949-c285-4f4b-ab07-d6fd2bbf7bb5"
},
{
"id": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"position": {
"x": 0.4000000059604645,
"y": 1.2999999523162842,
"z": 2.9000000953674316
},
"rotation": {
"x": 0,
"y": 3.141592653589793,
"z": 0
},
"last_seen": "2025-11-20T14:28:36.027Z",
"template": "#box-template",
"text": "server",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#006400",
"_id": "idb75dff6c-e056-4a15-b179-adfb2bec793a"
},
{
"from": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"to": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "idd6098a95-f534-4126-9aad-9948fdc724c6",
"_id": "idd6098a95-f534-4126-9aad-9948fdc724c6"
},
{
"id": "ide476ec05-9aac-42c9-87f1-ba7f18141767",
"position": {
"x": -0.6000000238418579,
"y": 1.2999999523162842,
"z": 2.799999952316284
},
"rotation": {
"x": -2.4492931757747437e-16,
"y": 3.141592653589793,
"z": -6.429647808784774e-40
},
"last_seen": "2025-11-20T14:30:59.486Z",
"template": "#box-template",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#0000FF",
"text": "api",
"_id": "ide476ec05-9aac-42c9-87f1-ba7f18141767"
},
{
"id": "idf1cf90c7-cc2f-4ccc-9bd6-274752f5b66f",
"position": {
"x": 0.4000000059604645,
"y": 2.200000047683716,
"z": 3
},
"rotation": {
"x": -2.4492937051703357e-16,
"y": 3.141592653589793,
"z": -2.4492937051703357e-16
},
"last_seen": "2025-11-20T14:28:58.876Z",
"template": "#person-template",
"text": "user",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#FFE4B5",
"_id": "idf1cf90c7-cc2f-4ccc-9bd6-274752f5b66f"
}
]
}

126
server.js
View File

@ -1,13 +1,133 @@
import express from "express";
import ViteExpress from "vite-express";
import cors from "cors";
import dotenv from "dotenv";
import expressProxy from "express-http-proxy";
import newrelic from "newrelic";
import apiRoutes from "./server/api/index.js";
import { pouchApp, PouchDB } from "./server/services/databaseService.js";
import { dbAuthMiddleware } from "./server/middleware/dbAuth.js";
// Load .env.local first, then fall back to .env
dotenv.config({ path: '.env.local' });
dotenv.config();
// Console shim to forward logs to New Relic while preserving local output
const originalConsole = {
log: console.log.bind(console),
error: console.error.bind(console),
warn: console.warn.bind(console),
info: console.info.bind(console)
};
function forwardToNewRelic(level, args) {
const message = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
).join(' ');
newrelic.recordLogEvent({
message,
level,
timestamp: Date.now()
});
}
console.log = (...args) => {
forwardToNewRelic('info', args);
originalConsole.log(...args);
};
console.error = (...args) => {
forwardToNewRelic('error', args);
originalConsole.error(...args);
};
console.warn = (...args) => {
forwardToNewRelic('warn', args);
originalConsole.warn(...args);
};
console.info = (...args) => {
forwardToNewRelic('info', args);
originalConsole.info(...args);
};
const app = express();
app.use("/api", expressProxy("local.immersiveidea.com"));
ViteExpress.listen(app, process.env.PORT || 3001, () => console.log("Server is listening..."));
// CORS configuration for split deployment
// In combined mode, same-origin requests don't need CORS
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [];
if (allowedOrigins.length > 0) {
app.use(cors({
origin: allowedOrigins,
credentials: true,
}));
}
// Parse JSON for all routes EXCEPT /pouchdb (express-pouchdb handles its own body parsing)
app.use((req, res, next) => {
if (req.path.startsWith('/pouchdb')) {
return next();
}
express.json()(req, res, next);
});
// API routes
app.use("/api", apiRoutes);
// Test endpoint to verify PouchDB is working
app.get("/pouchdb-test/:dbname", async (req, res) => {
try {
const dbName = req.params.dbname;
console.log(`[Test] Creating database: ${dbName}`);
const db = new PouchDB(dbName);
const info = await db.info();
console.log(`[Test] Database info:`, info);
// Try to add a test doc
const result = await db.put({ _id: 'test-doc', hello: 'world' });
console.log(`[Test] Added doc:`, result);
// Read it back
const doc = await db.get('test-doc');
console.log(`[Test] Got doc:`, doc);
res.json({ success: true, info, doc });
} catch (err) {
console.error(`[Test] Error:`, err);
res.status(500).json({ error: err.message, stack: err.stack });
}
});
// PouchDB database sync endpoint with auth middleware
// Public databases (/pouchdb/public-*) are accessible without auth
// Private databases (/pouchdb/private-*) require authentication
// Patch req.query for Express 5 compatibility with express-pouchdb
app.use("/pouchdb", dbAuthMiddleware, (req, res, next) => {
// Express 5 makes req.query read-only, but express-pouchdb needs to write to it
// Redefine as writable property
Object.defineProperty(req, 'query', {
value: { ...req.query },
writable: true,
configurable: true
});
next();
}, pouchApp, (err, req, res, next) => {
console.error('[PouchDB Error]', err);
res.status(500).json({ error: err.message, stack: err.stack });
});
// Check if running in API-only mode (split deployment)
const apiOnly = process.env.API_ONLY === "true";
if (apiOnly) {
// API-only mode: no static file serving
app.listen(process.env.PORT || 3000, () => {
console.log(`API server running on port ${process.env.PORT || 3000}`);
});
} else {
// Combined mode: Vite handles static files + SPA
ViteExpress.listen(app, process.env.PORT || 3001, () => {
console.log(`Server running on port ${process.env.PORT || 3001}`);
});
}

178
server/api/claude.js Normal file
View File

@ -0,0 +1,178 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
import { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js";
const router = Router();
const ANTHROPIC_API_URL = "https://api.anthropic.com";
/**
* Build entity context string for the system prompt
*/
function buildEntityContext(entities) {
if (!entities || entities.length === 0) {
return "\n\nThe diagram is currently empty.";
}
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
}
// Express 5 uses named parameters for wildcards
router.post("/*path", async (req, res) => {
const requestStart = Date.now();
console.log(`[Claude API] ========== REQUEST START ==========`);
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
console.error(`[Claude API] ERROR: API key not configured`);
return res.status(500).json({ error: "API key not configured" });
}
// Get the path after /api/claude (e.g., /v1/messages)
// Express 5 returns path segments as an array
const pathParam = req.params.path;
const path = "/" + (Array.isArray(pathParam) ? pathParam.join("/") : pathParam || "");
console.log(`[Claude API] Path: ${path}`);
// Check for session-based request
const { sessionId, ...requestBody } = req.body;
let modifiedBody = requestBody;
console.log(`[Claude API] Session ID: ${sessionId || 'none'}`);
console.log(`[Claude API] Model: ${requestBody.model}`);
console.log(`[Claude API] Messages count: ${requestBody.messages?.length || 0}`);
if (sessionId) {
const session = getSession(sessionId);
if (session) {
console.log(`[Claude API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
// Inject entity context into system prompt
if (modifiedBody.system) {
const entityContext = buildEntityContext(session.entities);
console.log(`[Claude API] Entity context added (${entityContext.length} chars)`);
modifiedBody.system += entityContext;
}
// Get conversation history and merge with current messages
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0 && modifiedBody.messages) {
// Filter out any duplicate messages (in case client sent history too)
const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content;
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages];
console.log(`[Claude API] Merged ${filteredHistory.length} history + ${modifiedBody.messages.length - filteredHistory.length} new = ${modifiedBody.messages.length} total messages`);
}
} else {
console.log(`[Claude API] WARNING: Session ${sessionId} not found`);
}
}
try {
console.log(`[Claude API] Sending request to Anthropic API...`);
const fetchStart = Date.now();
const response = await fetch(`${ANTHROPIC_API_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(modifiedBody),
});
const fetchDuration = Date.now() - fetchStart;
console.log(`[Claude API] Response received in ${fetchDuration}ms, status: ${response.status}`);
console.log(`[Claude API] Parsing response JSON...`);
const data = await response.json();
console.log(`[Claude API] Response parsed. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
// Track and log token usage
if (data.usage) {
// Extract content for detailed tracking
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
const outputText = data.content
?.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n') || null;
const toolCalls = data.content
?.filter(c => c.type === 'tool_use')
.map(c => ({ name: c.name, input: c.input })) || [];
const usageRecord = trackUsage(sessionId, modifiedBody.model, data.usage, {
inputText,
outputText,
toolCalls
});
console.log(`[Claude API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
// Log cumulative session usage if session exists
if (sessionId) {
const sessionStats = getSessionUsage(sessionId);
if (sessionStats) {
console.log(`[Claude API] SESSION TOTALS (${sessionStats.requestCount} requests):`);
console.log(`[Claude API] Total input: ${sessionStats.totalInputTokens} tokens`);
console.log(`[Claude API] Total output: ${sessionStats.totalOutputTokens} tokens`);
console.log(`[Claude API] Total cost: ${formatCost(sessionStats.totalCost)}`);
}
}
}
if (data.error) {
console.error(`[Claude API] API returned error:`, data.error);
}
// If session exists and response is successful, store messages
if (sessionId && response.ok && data.content) {
const session = getSession(sessionId);
if (session) {
// Store the user message if it was new (only if it's a string, not tool results)
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Claude API] Stored user message to session`);
}
// Store the assistant response (text only, not tool use blocks)
const assistantContent = data.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Claude API] Stored assistant response to session (${assistantContent.length} chars)`);
}
}
}
const totalDuration = Date.now() - requestStart;
console.log(`[Claude API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.status(response.status).json(data);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Claude API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Claude API] Error:`, error);
console.error(`[Claude API] Error message:`, error.message);
console.error(`[Claude API] Error stack:`, error.stack);
res.status(500).json({ error: "Failed to proxy request to Claude API", details: error.message });
}
});
export default router;

213
server/api/cloudflare.js Normal file
View File

@ -0,0 +1,213 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
import { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js";
import { getCloudflareAccountId, getCloudflareApiToken } from "../services/providerConfig.js";
import {
claudeToolsToCloudflare,
claudeMessagesToCloudflare,
cloudflareResponseToClaude
} from "../services/toolConverter.js";
const router = Router();
/**
* Build entity context string for the system prompt
*/
function buildEntityContext(entities) {
if (!entities || entities.length === 0) {
return "\n\nThe diagram is currently empty.";
}
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
}
// Express 5 uses named parameters for wildcards
router.post("/*path", async (req, res) => {
const requestStart = Date.now();
console.log(`[Cloudflare API] ========== REQUEST START ==========`);
const accountId = getCloudflareAccountId();
const apiToken = getCloudflareApiToken();
if (!accountId) {
console.error(`[Cloudflare API] ERROR: Account ID not configured`);
return res.status(500).json({ error: "Cloudflare account ID not configured" });
}
if (!apiToken) {
console.error(`[Cloudflare API] ERROR: API token not configured`);
return res.status(500).json({ error: "Cloudflare API token not configured" });
}
// Check for session-based request
const { sessionId, ...requestBody } = req.body;
let modifiedBody = { ...requestBody };
const model = requestBody.model;
console.log(`[Cloudflare API] Session ID: ${sessionId || 'none'}`);
console.log(`[Cloudflare API] Model: ${model}`);
console.log(`[Cloudflare API] Messages count: ${requestBody.messages?.length || 0}`);
// Build system prompt with entity context
let systemPrompt = modifiedBody.system || '';
if (sessionId) {
const session = getSession(sessionId);
if (session) {
console.log(`[Cloudflare API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
// Inject entity context into system prompt
const entityContext = buildEntityContext(session.entities);
console.log(`[Cloudflare API] Entity context added (${entityContext.length} chars)`);
systemPrompt += entityContext;
// Get conversation history and merge with current messages
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0 && modifiedBody.messages) {
const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content;
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages];
console.log(`[Cloudflare API] Merged ${filteredHistory.length} history + ${modifiedBody.messages.length - filteredHistory.length} new = ${modifiedBody.messages.length} total messages`);
}
} else {
console.log(`[Cloudflare API] WARNING: Session ${sessionId} not found`);
}
}
try {
// Convert to Cloudflare format
const cfMessages = claudeMessagesToCloudflare(modifiedBody.messages || [], systemPrompt);
const cfTools = modifiedBody.tools ? claudeToolsToCloudflare(modifiedBody.tools) : undefined;
// Build Cloudflare request body
const cfRequestBody = {
messages: cfMessages,
max_tokens: modifiedBody.max_tokens || 1024
};
// Only include tools if the model supports them
if (cfTools && cfTools.length > 0) {
cfRequestBody.tools = cfTools;
}
// Cloudflare endpoint: https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model}
const endpoint = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`;
console.log(`[Cloudflare API] Sending request to: ${endpoint}`);
console.log(`[Cloudflare API] Request body messages: ${cfMessages.length}, tools: ${cfTools?.length || 0}`);
const requestBodyJson = JSON.stringify(cfRequestBody);
console.log(`[Cloudflare API] Full request body (${requestBodyJson.length} bytes):`);
console.log(requestBodyJson);
const fetchStart = Date.now();
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiToken}`,
},
body: JSON.stringify(cfRequestBody),
});
const fetchDuration = Date.now() - fetchStart;
console.log(`[Cloudflare API] Response received in ${fetchDuration}ms, status: ${response.status}`);
console.log(`[Cloudflare API] Parsing response JSON...`);
const cfData = await response.json();
if (!cfData.success) {
console.error(`[Cloudflare API] API returned error:`, cfData.errors);
return res.status(response.status).json({
error: cfData.errors?.[0]?.message || "Cloudflare API error",
details: cfData.errors
});
}
// Convert Cloudflare response to Claude format
const data = cloudflareResponseToClaude(cfData, model);
console.log(`[Cloudflare API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
// Track and log token usage
if (data.usage) {
// Extract content for detailed tracking
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
const outputText = data.content
?.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n') || null;
const toolCalls = data.content
?.filter(c => c.type === 'tool_use')
.map(c => ({ name: c.name, input: c.input })) || [];
const usageRecord = trackUsage(sessionId, model, data.usage, {
inputText,
outputText,
toolCalls
});
console.log(`[Cloudflare API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
// Log cumulative session usage if session exists
if (sessionId) {
const sessionStats = getSessionUsage(sessionId);
if (sessionStats) {
console.log(`[Cloudflare API] SESSION TOTALS (${sessionStats.requestCount} requests):`);
console.log(`[Cloudflare API] Total input: ${sessionStats.totalInputTokens} tokens`);
console.log(`[Cloudflare API] Total output: ${sessionStats.totalOutputTokens} tokens`);
console.log(`[Cloudflare API] Total cost: ${formatCost(sessionStats.totalCost)}`);
}
}
}
// If session exists and response is successful, store messages
if (sessionId && response.ok && data.content) {
const session = getSession(sessionId);
if (session) {
// Store the user message if it was new (only if it's a string, not tool results)
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Cloudflare API] Stored user message to session`);
}
// Store the assistant response (text only, not tool use blocks)
const assistantContent = data.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Cloudflare API] Stored assistant response to session (${assistantContent.length} chars)`);
}
}
}
const totalDuration = Date.now() - requestStart;
console.log(`[Cloudflare API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.status(response.status).json(data);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Cloudflare API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Cloudflare API] Error:`, error);
console.error(`[Cloudflare API] Error message:`, error.message);
console.error(`[Cloudflare API] Error stack:`, error.stack);
res.status(500).json({ error: "Failed to proxy request to Cloudflare API", details: error.message });
}
});
export default router;

30
server/api/index.js Normal file
View File

@ -0,0 +1,30 @@
import { Router } from "express";
import claudeRouter from "./claude.js";
import ollamaRouter from "./ollama.js";
import cloudflareRouter from "./cloudflare.js";
import sessionRouter from "./session.js";
import userRouter from "./user.js";
const router = Router();
// Session management
router.use("/session", sessionRouter);
// User features
router.use("/user", userRouter);
// Claude API proxy
router.use("/claude", claudeRouter);
// Ollama API proxy
router.use("/ollama", ollamaRouter);
// Cloudflare Workers AI proxy
router.use("/cloudflare", cloudflareRouter);
// Health check
router.get("/health", (req, res) => {
res.json({ status: "ok" });
});
export default router;

178
server/api/ollama.js Normal file
View File

@ -0,0 +1,178 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
import { getOllamaUrl } from "../services/providerConfig.js";
import {
claudeToolsToOllama,
claudeMessagesToOllama,
ollamaResponseToClaude
} from "../services/toolConverter.js";
const router = Router();
/**
* Build entity context string for the system prompt
*/
function buildEntityContext(entities) {
if (!entities || entities.length === 0) {
return "\n\nThe diagram is currently empty.";
}
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
}
/**
* Handle Ollama chat requests
* Accepts Claude-format requests and converts them to Ollama format
*/
router.post("/*path", async (req, res) => {
const requestStart = Date.now();
console.log(`[Ollama API] ========== REQUEST START ==========`);
const ollamaUrl = getOllamaUrl();
console.log(`[Ollama API] Using Ollama at: ${ollamaUrl}`);
// Extract request body (Claude format)
const { sessionId, model, max_tokens, system, tools, messages } = req.body;
console.log(`[Ollama API] Session ID: ${sessionId || 'none'}`);
console.log(`[Ollama API] Model: ${model}`);
console.log(`[Ollama API] Messages count: ${messages?.length || 0}`);
// Build system prompt with entity context
let systemPrompt = system || '';
if (sessionId) {
const session = getSession(sessionId);
if (session) {
console.log(`[Ollama API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
// Inject entity context into system prompt
const entityContext = buildEntityContext(session.entities);
console.log(`[Ollama API] Entity context added (${entityContext.length} chars)`);
systemPrompt += entityContext;
// Get conversation history and merge with current messages
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0 && messages) {
const currentContent = messages[messages.length - 1]?.content;
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
messages.unshift(...filteredHistory);
console.log(`[Ollama API] Merged ${filteredHistory.length} history messages`);
}
} else {
console.log(`[Ollama API] WARNING: Session ${sessionId} not found`);
}
}
// Convert to Ollama format
const ollamaMessages = claudeMessagesToOllama(messages || [], systemPrompt);
const ollamaTools = claudeToolsToOllama(tools);
const ollamaRequest = {
model: model,
messages: ollamaMessages,
stream: false,
options: {
num_predict: max_tokens || 1024
}
};
// Only add tools if there are any
if (ollamaTools.length > 0) {
ollamaRequest.tools = ollamaTools;
}
console.log(`[Ollama API] Converted to Ollama format: ${ollamaMessages.length} messages, ${ollamaTools.length} tools`);
try {
console.log(`[Ollama API] Sending request to Ollama...`);
const fetchStart = Date.now();
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(ollamaRequest)
});
const fetchDuration = Date.now() - fetchStart;
console.log(`[Ollama API] Response received in ${fetchDuration}ms, status: ${response.status}`);
if (!response.ok) {
const errorText = await response.text();
console.error(`[Ollama API] Error response:`, errorText);
return res.status(response.status).json({
error: `Ollama API error: ${response.status}`,
details: errorText
});
}
const ollamaData = await response.json();
console.log(`[Ollama API] Response parsed. Done: ${ollamaData.done}, model: ${ollamaData.model}`);
// Convert response back to Claude format
const claudeResponse = ollamaResponseToClaude(ollamaData);
console.log(`[Ollama API] Converted to Claude format. Stop reason: ${claudeResponse.stop_reason}, content blocks: ${claudeResponse.content.length}`);
// Store messages to session if applicable
if (sessionId && claudeResponse.content) {
const session = getSession(sessionId);
if (session) {
// Store the user message if it was new
const userMessage = messages?.[messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Ollama API] Stored user message to session`);
}
// Store the assistant response (text only)
const assistantContent = claudeResponse.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Ollama API] Stored assistant response to session (${assistantContent.length} chars)`);
}
}
}
const totalDuration = Date.now() - requestStart;
console.log(`[Ollama API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.json(claudeResponse);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Ollama API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Ollama API] Error:`, error);
// Check if it's a connection error
if (error.cause?.code === 'ECONNREFUSED') {
return res.status(503).json({
error: "Ollama is not running",
details: `Could not connect to Ollama at ${ollamaUrl}. Make sure Ollama is installed and running.`
});
}
res.status(500).json({
error: "Failed to proxy request to Ollama",
details: error.message
});
}
});
export default router;

176
server/api/session.js Normal file
View File

@ -0,0 +1,176 @@
import { Router } from "express";
import {
createSession,
getSession,
findSessionByDiagram,
syncEntities,
addMessage,
clearHistory,
deleteSession,
getStats
} from "../services/sessionStore.js";
import { getSessionUsage, getGlobalUsage, formatCost } from "../services/usageTracker.js";
const router = Router();
/**
* GET /api/session/debug/stats
* Get session statistics (for debugging)
* Query params:
* - details=true: Include full entity and conversation data
* NOTE: Must be before /:id routes to avoid matching "debug" as an id
*/
router.get("/debug/stats", (req, res) => {
const includeDetails = req.query.details === 'true';
const stats = getStats(includeDetails);
console.log('[Session Debug] Stats requested:', JSON.stringify(stats, null, 2));
res.json(stats);
});
/**
* GET /api/session/usage/global
* Get global token usage and cost statistics
* NOTE: Must be before /:id routes
*/
router.get("/usage/global", (req, res) => {
const usage = getGlobalUsage();
res.json({
...usage,
totalCostFormatted: formatCost(usage.totalCost),
uptimeFormatted: `${Math.round(usage.uptime / 1000 / 60)} minutes`
});
});
/**
* GET /api/session/:id/usage
* Get token usage and cost for a specific session
*/
router.get("/:id/usage", (req, res) => {
const usage = getSessionUsage(req.params.id);
if (!usage) {
return res.status(404).json({ error: "No usage data for session" });
}
res.json({
...usage,
totalCostFormatted: formatCost(usage.totalCost)
});
});
/**
* POST /api/session/create
* Create a new session or return existing one for a diagram
*/
router.post("/create", (req, res) => {
const { diagramId } = req.body;
if (!diagramId) {
return res.status(400).json({ error: "diagramId is required" });
}
// Check for existing session
let session = findSessionByDiagram(diagramId);
if (session) {
console.log(`[Session] Resuming existing session ${session.id} for diagram ${diagramId} (${session.conversationHistory.length} messages, ${session.entities.length} entities)`);
return res.json({
session,
isNew: false
});
}
// Create new session
session = createSession(diagramId);
console.log(`[Session] Created new session ${session.id} for diagram ${diagramId}`);
res.json({
session,
isNew: true
});
});
/**
* GET /api/session/:id
* Get session details including history
*/
router.get("/:id", (req, res) => {
const session = getSession(req.params.id);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ session });
});
/**
* PUT /api/session/:id/sync
* Sync entities from client to server
*/
router.put("/:id/sync", (req, res) => {
const { entities } = req.body;
if (!entities || !Array.isArray(entities)) {
return res.status(400).json({ error: "entities array is required" });
}
const session = syncEntities(req.params.id, entities);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
console.log(`[Session ${req.params.id}] Synced ${entities.length} entities:`,
entities.map(e => `${e.text || '(no label)'} (${e.template})`).join(', ') || 'none');
res.json({ success: true, entityCount: entities.length });
});
/**
* POST /api/session/:id/message
* Add a message to history (used after successful Claude response)
*/
router.post("/:id/message", (req, res) => {
const { role, content, toolResults } = req.body;
if (!role || !content) {
return res.status(400).json({ error: "role and content are required" });
}
const session = addMessage(req.params.id, { role, content, toolResults });
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ success: true, messageCount: session.conversationHistory.length });
});
/**
* DELETE /api/session/:id/history
* Clear conversation history
*/
router.delete("/:id/history", (req, res) => {
const session = clearHistory(req.params.id);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ success: true });
});
/**
* DELETE /api/session/:id
* Delete a session entirely
*/
router.delete("/:id", (req, res) => {
const deleted = deleteSession(req.params.id);
if (!deleted) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ success: true });
});
export default router;

155
server/api/user.js Normal file
View File

@ -0,0 +1,155 @@
import { Router } from "express";
const router = Router();
// Feature configurations by tier
const FEATURE_CONFIGS = {
none: {
tier: 'none',
pages: {
examples: 'off',
documentation: 'off',
pricing: 'off',
vrExperience: 'off',
},
features: {
createDiagram: 'off',
createFromTemplate: 'off',
manageDiagrams: 'off',
shareCollaborate: 'off',
privateDesigns: 'off',
encryptedDesigns: 'off',
editData: 'off',
config: 'off',
enterImmersive: 'off',
launchMetaQuest: 'off',
},
limits: {
maxDiagrams: 0,
maxCollaborators: 0,
storageQuotaMB: 0,
},
},
free: {
tier: 'free',
pages: {
examples: 'on',
documentation: 'on',
pricing: 'on',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'coming-soon',
manageDiagrams: 'on',
shareCollaborate: 'on',
privateDesigns: 'coming-soon',
encryptedDesigns: 'pro',
editData: 'on',
config: 'on',
enterImmersive: 'on',
launchMetaQuest: 'on',
},
limits: {
maxDiagrams: 6,
maxCollaborators: 0,
storageQuotaMB: 100,
},
},
basic: {
tier: 'basic',
pages: {
examples: 'on',
documentation: 'on',
pricing: 'on',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'on',
manageDiagrams: 'on',
shareCollaborate: 'on',
privateDesigns: 'on',
encryptedDesigns: 'pro',
editData: 'on',
config: 'on',
enterImmersive: 'on',
launchMetaQuest: 'on',
},
limits: {
maxDiagrams: 25,
maxCollaborators: 0,
storageQuotaMB: 500,
},
},
pro: {
tier: 'pro',
pages: {
examples: 'on',
documentation: 'on',
pricing: 'on',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'on',
manageDiagrams: 'on',
shareCollaborate: 'on',
privateDesigns: 'on',
encryptedDesigns: 'on',
editData: 'on',
config: 'on',
enterImmersive: 'on',
launchMetaQuest: 'on',
},
limits: {
maxDiagrams: -1,
maxCollaborators: -1,
storageQuotaMB: -1,
},
},
};
// Default tier for authenticated users without a specific tier
const DEFAULT_TIER = 'basic';
/**
* GET /api/user/features
* Returns feature configuration for the current user
*
* Query params:
* - tier: Override tier for testing (e.g., ?tier=pro)
*/
router.get("/features", (req, res) => {
// Allow tier override via query param for testing
const tierOverride = req.query.tier;
// TODO: In production, determine tier from JWT token or user database
// For now, use query param override or default to 'basic'
const tier = tierOverride && FEATURE_CONFIGS[tierOverride]
? tierOverride
: DEFAULT_TIER;
const config = FEATURE_CONFIGS[tier];
console.log(`[User] Returning feature config for tier: ${tier}`);
res.json(config);
});
/**
* GET /api/user/features/:tier
* Returns feature configuration for a specific tier (for testing/admin)
*/
router.get("/features/:tier", (req, res) => {
const { tier } = req.params;
const config = FEATURE_CONFIGS[tier];
if (!config) {
return res.status(404).json({ error: `Unknown tier: ${tier}` });
}
console.log(`[User] Returning feature config for tier: ${tier}`);
res.json(config);
});
export default router;

19
server/etc/cert.pem Normal file
View File

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDHTCCAgWgAwIBAgIJF3WqWLMk6JOlMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNV
BAMTIWRldi1nMGx0MThuZGJjcDZlYXJyLnVzLmF1dGgwLmNvbTAeFw0yNDA4MTUx
NTE1NDdaFw0zODA0MjQxNTE1NDdaMCwxKjAoBgNVBAMTIWRldi1nMGx0MThuZGJj
cDZlYXJyLnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAKfmuyqb2N8Tf5W9KTAy2D6FTkYICX+mcclyZS+0Mi7fLzSB7IeSuWXmuHoR
h5FHJ/Qp6eC1ahYs9WmAjFp81HPzZ/9hEbK3XrLMSta7zVldPTQjnt5sU/Zxr/M2
xMjHH2P3G231si+G20czvDWoItnyWs8rcE2wEcyiXM+/Ixgxoh8kfc9pqpNLXTvM
IvqAuxXbPeju3XccQ6B0lshN72EwV9yW73B0s7DuHsbBA0WHKYmcvdXgnQ1dU2/L
8BR5s/gJJE0MUh2qhsnKE3yUC/hTW7A0Qn0SMEZey04hvJWePnn59kv52DPVXZpZ
ql6ISehwn3hZdhHjpsoHbE48CN0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAd
BgNVHQ4EFgQUbjF1ri0QhHovlQ2D5gPtDucLGhowDgYDVR0PAQH/BAQDAgKEMA0G
CSqGSIb3DQEBCwUAA4IBAQAxIRNANDbVrkXF6x/KHYgj5prb+4yHtmYb7tiRi51z
MNHNLkltNou3dWsS4tU/YgzHTof3SJe2CIg9xAgk0XTHZjxRtbwIY6Zc9Sgf/KKL
OxFIiNcIQIGDoKHWmv2w4qSrYBkH9hva4kCysjgIFNc+0il7DQR2ifwLOxQGl/AE
hSfexgUKjfrno12gBlNCNcP+Xyn9/G++eg9vV+RuGLLIyLX0d0Vl7/C1pGoDrNpO
m/3oxR4IRnhEfGBD+LdWvmmIuxzXM1hSbLYJbMotHqKZSh0XlEM6Mi12gMZi7sEC
lhbXs+4ecvTBFfGCWFyUISFoSwRRnpQnEM5DsZT/t/Z8
-----END CERTIFICATE-----

126
server/etc/local.ini Normal file
View File

@ -0,0 +1,126 @@
; CouchDB Configuration Settings
; Custom settings should be made in this file. They will override settings
; in default.ini, but unlike changes made to default.ini, this file won't be
; overwritten on server upgrade.
[couchdb]
database_dir = /var/snap/couchdb/common/data
view_index_dir = /var/snap/couchdb/common/data
;max_document_size = 4294967296 ; bytes
;os_process_timeout = 5000
uuid = dd27b78cfb458b894e0277173f176878
[couch_peruser]
; If enabled, couch_peruser ensures that a private per-user database
; exists for each document in _users. These databases are writable only
; by the corresponding user. Databases are in the following form:
; userdb-{hex encoded username}
enable = true
; If set to true and a user is deleted, the respective database gets
; deleted as well.
delete_dbs = true
; Set a default q value for peruser-created databases that is different from
; cluster / q
;q = 1
[log]
level = debug
[chttpd]
;port = 5984
bind_address = 127.0.0.1
authentication_handlers = {chttpd_auth, jwt_authentication_handler}, {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}
;authentication_handlers = {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}
; Options for the MochiWeb HTTP server.
;server_options = [{backlog, 128}, {acceptor_pool_size, 16}]
; For more socket options, consult Erlang's module 'inet' man page.
;socket_options = [{sndbuf, 262144}, {nodelay, true}]
enable_cors = true
[httpd]
; NOTE that this only configures the "backend" node-local port, not the
; "frontend" clustered port. You probably don't want to change anything in
; this section.
; Uncomment next line to trigger basic-auth popup on unauthorized requests.
;WWW-Authenticate = Basic realm="administrator"
; Uncomment next line to set the configuration modification whitelist. Only
; whitelisted values may be changed via the /_config URLs. To allow the admin
; to change this value over HTTP, remember to include {httpd,config_whitelist}
; itself. Excluding it from the list would require editing this file to update
; the whitelist.
;config_whitelist = [{httpd,config_whitelist}, {log,level}, {etc,etc}]
[ssl]
;enable = true
;cert_file = /full/path/to/server_cert.pem
;key_file = /full/path/to/server_key.pem
;password = somepassword
; set to true to validate peer certificates
;verify_ssl_certificates = false
; Set to true to fail if the client does not send a certificate. Only used if verify_ssl_certificates is true.
;fail_if_no_peer_cert = false
; Path to file containing PEM encoded CA certificates (trusted
; certificates used for verifying a peer certificate). May be omitted if
; you do not want to verify the peer.
;cacert_file = /full/path/to/cacertf
; The verification fun (optional) if not specified, the default
; verification fun will be used.
;verify_fun = {Module, VerifyFun}
; maximum peer certificate depth
;ssl_certificate_max_depth = 1
; Reject renegotiations that do not live up to RFC 5746.
;secure_renegotiate = true
; The cipher suites that should be supported.
; Can be specified in erlang format "{ecdhe_ecdsa,aes_128_cbc,sha256}"
; or in OpenSSL format "ECDHE-ECDSA-AES128-SHA256".
;ciphers = ["ECDHE-ECDSA-AES128-SHA256", "ECDHE-ECDSA-AES128-SHA"]
; The SSL/TLS versions to support
;tls_versions = [tlsv1, 'tlsv1.1', 'tlsv1.2']
; To enable Virtual Hosts in CouchDB, add a vhost = path directive. All requests to
; the Virtual Host will be redirected to the path. In the example below all requests
; to http://example.com/ are redirected to /database.
; If you run CouchDB on a specific port, include the port number in the vhost:
; example.com:5984 = /database
[vhosts]
;example.com = /database/
; To create an admin account uncomment the '[admins]' section below and add a
; line in the format 'username = password'. When you next start CouchDB, it
; will change the password to a hash (so that your passwords don't linger
; around in plain-text files). You can add more admin accounts with more
; 'username = password' lines. Don't forget to restart CouchDB after
; changing this.
[admins]
admin = -pbkdf2-eeee185ee6142700c0e5a9e31b1d6d85ba952a49,f073f989f3201b55d953825d56acad2a,10
[chttpd_auth]
secret = a6bc1f1fd52803b4feae8f30b3944300
;authentication_handlers = {chttpd_auth, jwt_authentication_handler}
[jwt_auth]
roles_claim_path = metadata.databases
required_claims = exp,iat
;validate_claim_iss = https://dev-g0lt18ndbcp6earr.us.auth0.com/
;validate_claim_aud = sxAJub9Uo2mOE7iYCTOuQGhppGLEPWzb
[jwt_keys]
rsa:_default = -----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp+a7KpvY3xN/lb0pMDLY\nPoVORggJf6ZxyXJlL7QyLt8vNIHsh5K5Zea4ehGHkUcn9Cnp4LVqFiz1aYCMWnzU\nc/Nn/2ERsrdessxK1rvNWV09NCOe3mxT9nGv8zbEyMcfY/cbbfWyL4bbRzO8Nagi\n2fJazytwTbARzKJcz78jGDGiHyR9z2mqk0tdO8wi+oC7Fds96O7ddxxDoHSWyE3v\nYTBX3JbvcHSzsO4exsEDRYcpiZy91eCdDV1Tb8vwFHmz+AkkTQxSHaqGycoTfJQL\n+FNbsDRCfRIwRl7LTiG8lZ4+efn2S/nYM9VdmlmqXohJ6HCfeFl2EeOmygdsTjwI\n3QIDAQAB\n-----END PUBLIC KEY-----\n
rsa:1R0ZY6dzJ7ttWk60bT0_V = -----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp+a7KpvY3xN/lb0pMDLY\nPoVORggJf6ZxyXJlL7QyLt8vNIHsh5K5Zea4ehGHkUcn9Cnp4LVqFiz1aYCMWnzU\nc/Nn/2ERsrdessxK1rvNWV09NCOe3mxT9nGv8zbEyMcfY/cbbfWyL4bbRzO8Nagi\n2fJazytwTbARzKJcz78jGDGiHyR9z2mqk0tdO8wi+oC7Fds96O7ddxxDoHSWyE3v\nYTBX3JbvcHSzsO4exsEDRYcpiZy91eCdDV1Tb8vwFHmz+AkkTQxSHaqGycoTfJQL\n+FNbsDRCfRIwRl7LTiG8lZ4+efn2S/nYM9VdmlmqXohJ6HCfeFl2EeOmygdsTjwI\n3QIDAQAB\n-----END PUBLIC KEY-----\n
[cors]
origins = https://www.cybersecshield.com,https://cybersecshield.com,http://localhost:5173
headers = accept, authorization, content-type, origin, referer
credentials = true
methods = GET, PUT, POST, HEAD, DELETE

9
server/etc/pubkey.pem Normal file
View File

@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp+a7KpvY3xN/lb0pMDLY
PoVORggJf6ZxyXJlL7QyLt8vNIHsh5K5Zea4ehGHkUcn9Cnp4LVqFiz1aYCMWnzU
c/Nn/2ERsrdessxK1rvNWV09NCOe3mxT9nGv8zbEyMcfY/cbbfWyL4bbRzO8Nagi
2fJazytwTbARzKJcz78jGDGiHyR9z2mqk0tdO8wi+oC7Fds96O7ddxxDoHSWyE3v
YTBX3JbvcHSzsO4exsEDRYcpiZy91eCdDV1Tb8vwFHmz+AkkTQxSHaqGycoTfJQL
+FNbsDRCfRIwRl7LTiG8lZ4+efn2S/nYM9VdmlmqXohJ6HCfeFl2EeOmygdsTjwI
3QIDAQAB
-----END PUBLIC KEY-----

101
server/etc/vm.args Normal file
View File

@ -0,0 +1,101 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
# Each node in the system must have a unique name. These are specified through
# the Erlang -name flag, which takes the form:
#
# -name nodename@<FQDN>
#
# or
#
# -name nodename@<IP-ADDRESS>
#
# CouchDB recommends the following values for this flag:
#
# 1. If this is a single node, not in a cluster, use:
# -name couchdb@127.0.0.1
#
# 2. If DNS is configured for this host, use the FQDN, such as:
# -name couchdb@my.host.domain.com
#
# 3. If DNS isn't configured for this host, use IP addresses only, such as:
# -name couchdb@192.168.0.1
#
# Do not rely on tricks with /etc/hosts or libresolv to handle anything
# other than the above 3 approaches correctly. They will not work reliably.
#
# Multiple CouchDBs running on the same machine can use couchdb1@, couchdb2@,
# etc.
-name couchdb@127.0.0.1
# All nodes must share the same magic cookie for distributed Erlang to work.
# Uncomment the following line and append a securely generated random value.
-setcookie eh.RauybPRHzP4-pXv
# Which interfaces should the node listen on?
-kernel inet_dist_use_interface {127,0,0,1}
# Tell kernel and SASL not to log anything
-kernel error_logger silent
-sasl sasl_error_logger false
# This will toggle to true in Erlang 25+. However since we don't use global
# any longer, and have our own auto-connection module, we can keep the
# existing global behavior to avoid surprises. See
# https://github.com/erlang/otp/issues/6470#issuecomment-1337421210 for more
# information about possible increased coordination and messages being sent on
# disconnections when this setting is enabled.
#
-kernel prevent_overlapping_partitions false
# Increase the pool of dirty IO schedulers from 10 to 16
# Dirty IO schedulers are used for file IO.
+SDio 16
# Comment this line out to enable the interactive Erlang shell on startup
+Bd -noinput
# Set maximum SSL session lifetime to reap terminated replication readers
-ssl session_lifetime 300
## TLS Distribution
## Use TLS for connections between Erlang cluster members.
## http://erlang.org/doc/apps/ssl/ssl_distribution.html
##
## Generate Cert(PEM) File
## This is just an example command to generate a certfile (PEM).
## This is not an endorsement of specific expiration limits, key sizes, or algorithms.
## $ openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout key.pem -out cert.pem
## $ cat key.pem cert.pem > dev/erlserver.pem && rm key.pem cert.pem
##
## Generate a Config File (couch_ssl_dist.conf)
## [{server,
## [{certfile, "</path/to/erlserver.pem>"},
## {secure_renegotiate, true}]},
## {client,
## [{secure_renegotiate, true}]}].
##
## CouchDB recommends the following values for no_tls flag:
## 1. Use TCP only, set to true, such as:
## -couch_dist no_tls true
## 2. Use TLS only, set to false, such as:
## -couch_dist no_tls false
## 3. Specify which node to use TCP, such as:
## -couch_dist no_tls \"*@127.0.0.1\"
##
## To ensure search works, make sure to set 'no_tls' option for the clouseau node.
## By default that would be "clouseau@127.0.0.1".
## Don't forget to override the paths to point to your certificate(s) and key(s)!
##
#-proto_dist couch
#-couch_dist no_tls '"clouseau@127.0.0.1"'
#-ssl_dist_optfile <path/to/couch_ssl_dist.conf>

View File

@ -0,0 +1,88 @@
/**
* Database authentication middleware.
* Allows public databases to be accessed without auth.
* Private databases require authentication.
*/
/**
* Middleware to handle database authentication based on path.
*
* Database naming patterns:
* / - Root endpoint, always allowed (server info)
* /local-{dbname} - Should never reach server (client-only), return 404
* /public-{dbname} - No auth required, anyone can read/write
* /private-{dbname} - Auth required
* /{dbname} - Treated as private by default
*/
export function dbAuthMiddleware(req, res, next) {
// Extract the database name (first segment after /pouchdb/)
const pathParts = req.path.split('/').filter(Boolean);
const dbName = pathParts[0] || '';
// Allow root endpoint (server info check)
if (req.path === '/' || req.path === '') {
console.log(`[DB Auth] Root access: ${req.method} ${req.path}`);
return next();
}
// Local databases should never reach the server (they're browser-only)
if (dbName.startsWith('local-')) {
console.log(`[DB Auth] Local database access rejected: ${req.method} ${req.path}`);
return res.status(404).json({
error: 'not_found',
reason: 'Local databases are browser-only and do not sync to server'
});
}
// Check if this is a public database (name starts with 'public-')
const isPublic = dbName.startsWith('public-');
if (isPublic) {
// No auth required for public databases
console.log(`[DB Auth] Public access: ${req.method} ${req.path}`);
return next();
}
// For private databases, check for auth header
const auth = req.headers.authorization;
if (!auth) {
console.log(`[DB Auth] Unauthorized access attempt: ${req.method} ${req.path}`);
return res.status(401).json({
error: 'unauthorized',
reason: 'Authentication required for private databases'
});
}
// Parse Basic auth header
if (auth.startsWith('Basic ')) {
try {
const credentials = Buffer.from(auth.slice(6), 'base64').toString();
const [username, password] = credentials.split(':');
// For now, accept any credentials for private databases
// TODO: Implement proper user verification
req.dbUser = { name: username };
console.log(`[DB Auth] Authenticated: ${username} accessing ${req.path}`);
return next();
} catch (err) {
console.log(`[DB Auth] Invalid auth header: ${err.message}`);
}
}
// TODO: Add JWT/Bearer token support for Auth0 integration
if (auth.startsWith('Bearer ')) {
// For now, accept bearer tokens without verification
// TODO: Verify JWT with Auth0
req.dbUser = { name: 'bearer-user' };
console.log(`[DB Auth] Bearer token access: ${req.path}`);
return next();
}
return res.status(401).json({
error: 'unauthorized',
reason: 'Invalid authentication'
});
}
export default dbAuthMiddleware;

View File

@ -0,0 +1,62 @@
/**
* Database service using express-pouchdb for self-hosted database sync.
* Provides PouchDB HTTP API compatible with client-side PouchDB replication.
*/
import PouchDB from 'pouchdb';
import PouchDBAdapterMemory from 'pouchdb-adapter-memory';
import expressPouchdb from 'express-pouchdb';
import path from 'path';
import { fileURLToPath } from 'url';
// Register memory adapter (works in Node.js without leveldown issues)
PouchDB.plugin(PouchDBAdapterMemory);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Data directory for persistent storage (used for logs)
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
// Use memory adapter for now - data persists while server is running
// TODO: Switch to leveldb once version conflicts are resolved
const memPouchDB = PouchDB.defaults({
adapter: 'memory'
});
// Create express-pouchdb middleware
// Using 'minimumForPouchDB' mode for lightweight operation
// Include routes needed for PouchDB replication
const pouchApp = expressPouchdb(memPouchDB, {
mode: 'minimumForPouchDB',
overrideMode: {
include: [
'routes/root', // GET / - server info
'routes/db', // PUT/GET/DELETE /:db
'routes/all-dbs', // GET /_all_dbs
'routes/changes', // GET /:db/_changes
'routes/bulk-docs', // POST /:db/_bulk_docs
'routes/bulk-get', // POST /:db/_bulk_get
'routes/all-docs', // GET /:db/_all_docs
'routes/revs-diff', // POST /:db/_revs_diff
'routes/documents' // GET/PUT/DELETE /:db/:docid
]
},
logPath: path.join(DATA_DIR, 'logs', 'pouchdb.log')
});
console.log(`[Database] Initialized express-pouchdb with data dir: ${DATA_DIR}`);
// Test that PouchDB can create databases
(async () => {
try {
const testDb = new memPouchDB('_test_db');
const info = await testDb.info();
console.log('[Database] Test DB created successfully:', info);
await testDb.destroy();
console.log('[Database] Test DB destroyed');
} catch (err) {
console.error('[Database] Failed to create test database:', err);
}
})();
export { memPouchDB as PouchDB, pouchApp };

View File

@ -0,0 +1,140 @@
/**
* AI Provider Configuration
* Manages configuration for different AI providers (Claude, Ollama, Cloudflare)
*/
// Default configuration
const DEFAULT_PROVIDER = 'claude';
// Use 127.0.0.1 instead of localhost to avoid IPv6 resolution issues
const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434';
const DEFAULT_CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || '';
/**
* Get the current AI provider
* @returns {string} Provider name ('claude' or 'ollama')
*/
export function getProvider() {
return process.env.AI_PROVIDER || DEFAULT_PROVIDER;
}
/**
* Get Ollama API URL
* @returns {string} Ollama base URL
*/
export function getOllamaUrl() {
return process.env.OLLAMA_URL || DEFAULT_OLLAMA_URL;
}
/**
* Get Anthropic API URL
* @returns {string} Anthropic base URL
*/
export function getAnthropicUrl() {
return process.env.ANTHROPIC_API_URL || 'https://api.anthropic.com';
}
/**
* Get Cloudflare Account ID
* @returns {string} Cloudflare account ID
*/
export function getCloudflareAccountId() {
return process.env.CLOUDFLARE_ACCOUNT_ID || DEFAULT_CLOUDFLARE_ACCOUNT_ID;
}
/**
* Get Cloudflare API Token
* @returns {string} Cloudflare API token
*/
export function getCloudflareApiToken() {
return process.env.CLOUDFLARE_API_TOKEN || '';
}
/**
* Get Cloudflare Workers AI base URL
* @returns {string} Cloudflare Workers AI base URL
*/
export function getCloudflareUrl() {
const accountId = getCloudflareAccountId();
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run`;
}
/**
* Get provider configuration for a specific provider
* @param {string} provider - Provider name
* @returns {object} Provider configuration
*/
export function getProviderConfig(provider) {
switch (provider) {
case 'ollama':
return {
name: 'ollama',
baseUrl: getOllamaUrl(),
chatEndpoint: '/api/chat',
requiresAuth: false
};
case 'cloudflare':
return {
name: 'cloudflare',
baseUrl: getCloudflareUrl(),
chatEndpoint: '', // Model is appended to baseUrl
requiresAuth: true,
apiKey: getCloudflareApiToken(),
accountId: getCloudflareAccountId()
};
case 'claude':
default:
return {
name: 'claude',
baseUrl: getAnthropicUrl(),
chatEndpoint: '/v1/messages',
requiresAuth: true,
apiKey: process.env.ANTHROPIC_API_KEY
};
}
}
/**
* Determine provider from model ID
* @param {string} modelId - Model identifier
* @returns {string} Provider name
*/
export function getProviderFromModel(modelId) {
if (!modelId) return getProvider();
// Claude models start with 'claude-'
if (modelId.startsWith('claude-')) {
return 'claude';
}
// Cloudflare models start with '@cf/' or '@hf/'
if (modelId.startsWith('@cf/') || modelId.startsWith('@hf/')) {
return 'cloudflare';
}
// Known Ollama models
const ollamaModels = [
'llama', 'mistral', 'qwen', 'codellama', 'phi',
'gemma', 'neural-chat', 'starling', 'orca', 'vicuna',
'deepseek', 'dolphin', 'nous-hermes', 'openhermes'
];
for (const prefix of ollamaModels) {
if (modelId.toLowerCase().startsWith(prefix)) {
return 'ollama';
}
}
// Default to configured provider
return getProvider();
}
export default {
getProvider,
getOllamaUrl,
getAnthropicUrl,
getCloudflareAccountId,
getCloudflareApiToken,
getCloudflareUrl,
getProviderConfig,
getProviderFromModel
};

View File

@ -0,0 +1,158 @@
/**
* In-memory session store for diagram chat sessions.
* Stores conversation history and entity snapshots.
*/
import { v4 as uuidv4 } from 'uuid';
// Session structure:
// {
// id: string,
// diagramId: string,
// conversationHistory: Array<{role, content, toolResults?, timestamp}>,
// entities: Array<{id, template, text, color, position}>,
// createdAt: Date,
// lastAccess: Date
// }
const sessions = new Map();
// Session timeout (1 hour of inactivity)
const SESSION_TIMEOUT_MS = 60 * 60 * 1000;
/**
* Create a new session for a diagram
*/
export function createSession(diagramId) {
const id = uuidv4();
const session = {
id,
diagramId,
conversationHistory: [],
entities: [],
createdAt: new Date(),
lastAccess: new Date()
};
sessions.set(id, session);
return session;
}
/**
* Get a session by ID
*/
export function getSession(sessionId) {
const session = sessions.get(sessionId);
if (session) {
session.lastAccess = new Date();
}
return session || null;
}
/**
* Find existing session for a diagram
*/
export function findSessionByDiagram(diagramId) {
for (const [, session] of sessions) {
if (session.diagramId === diagramId) {
session.lastAccess = new Date();
return session;
}
}
return null;
}
/**
* Update entities snapshot for a session
*/
export function syncEntities(sessionId, entities) {
const session = sessions.get(sessionId);
if (!session) return null;
session.entities = entities;
session.lastAccess = new Date();
return session;
}
/**
* Add a message to conversation history
*/
export function addMessage(sessionId, message) {
const session = sessions.get(sessionId);
if (!session) return null;
session.conversationHistory.push({
...message,
timestamp: new Date()
});
session.lastAccess = new Date();
return session;
}
/**
* Get conversation history for API calls (formatted for Claude)
*/
export function getConversationForAPI(sessionId) {
const session = sessions.get(sessionId);
if (!session) return [];
// Convert to Claude message format
return session.conversationHistory.map(msg => ({
role: msg.role,
content: msg.content
}));
}
/**
* Clear conversation history but keep session
*/
export function clearHistory(sessionId) {
const session = sessions.get(sessionId);
if (!session) return null;
session.conversationHistory = [];
session.lastAccess = new Date();
return session;
}
/**
* Delete a session
*/
export function deleteSession(sessionId) {
return sessions.delete(sessionId);
}
/**
* Clean up expired sessions
*/
export function cleanupExpiredSessions() {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.lastAccess.getTime() > SESSION_TIMEOUT_MS) {
sessions.delete(id);
}
}
}
// Run cleanup every 15 minutes
setInterval(cleanupExpiredSessions, 15 * 60 * 1000);
/**
* Get session stats (for debugging)
*/
export function getStats(includeDetails = false) {
return {
activeSessions: sessions.size,
sessions: Array.from(sessions.values()).map(s => ({
id: s.id,
diagramId: s.diagramId,
messageCount: s.conversationHistory.length,
entityCount: s.entities.length,
lastAccess: s.lastAccess,
// Include full details if requested
...(includeDetails && {
entities: s.entities,
conversationHistory: s.conversationHistory
})
}))
};
}

View File

@ -0,0 +1,663 @@
/**
* Tool Format Converter
* Converts between Claude and Ollama tool/function formats
*/
/**
* Convert Claude tool definition to Ollama function format
*
* Claude format:
* { name: "...", description: "...", input_schema: { type: "object", properties: {...} } }
*
* Ollama format:
* { type: "function", function: { name: "...", description: "...", parameters: {...} } }
*
* @param {object} claudeTool - Tool in Claude format
* @returns {object} Tool in Ollama format
*/
export function claudeToolToOllama(claudeTool) {
return {
type: "function",
function: {
name: claudeTool.name,
description: claudeTool.description,
parameters: claudeTool.input_schema
}
};
}
/**
* Convert array of Claude tools to Ollama format
* @param {Array} claudeTools - Array of Claude tool definitions
* @returns {Array} Array of Ollama function definitions
*/
export function claudeToolsToOllama(claudeTools) {
if (!claudeTools || !Array.isArray(claudeTools)) {
return [];
}
return claudeTools.map(claudeToolToOllama);
}
/**
* Convert Ollama tool call to Claude format
*
* Ollama format (in message):
* { tool_calls: [{ function: { name: "...", arguments: {...} } }] }
*
* Claude format:
* { type: "tool_use", id: "...", name: "...", input: {...} }
*
* @param {object} ollamaToolCall - Tool call from Ollama response
* @param {number} index - Index for generating unique ID
* @returns {object} Tool call in Claude format
*/
export function ollamaToolCallToClaude(ollamaToolCall, index = 0) {
const func = ollamaToolCall.function;
// Parse arguments if it's a string
let input = func.arguments;
if (typeof input === 'string') {
try {
input = JSON.parse(input);
} catch (e) {
console.warn('[ToolConverter] Failed to parse tool arguments:', e);
input = {};
}
}
return {
type: "tool_use",
id: `toolu_ollama_${Date.now()}_${index}`,
name: func.name,
input: input || {}
};
}
/**
* Convert Claude tool result to Ollama format
*
* Claude format (in messages):
* { role: "user", content: [{ type: "tool_result", tool_use_id: "...", content: "..." }] }
*
* Ollama format:
* { role: "tool", content: "...", name: "..." }
*
* @param {object} claudeToolResult - Tool result in Claude format
* @param {string} toolName - Name of the tool (from previous tool_use)
* @returns {object} Tool result in Ollama message format
*/
export function claudeToolResultToOllama(claudeToolResult, toolName) {
let content = claudeToolResult.content;
// Stringify if it's an object
if (typeof content === 'object') {
content = JSON.stringify(content);
}
return {
role: "tool",
content: content,
name: toolName
};
}
/**
* Convert Claude messages array to Ollama format
* Handles regular messages and tool result messages
*
* @param {Array} claudeMessages - Messages in Claude format
* @param {string} systemPrompt - System prompt to prepend
* @returns {Array} Messages in Ollama format
*/
export function claudeMessagesToOllama(claudeMessages, systemPrompt) {
const ollamaMessages = [];
// Add system message if provided
if (systemPrompt) {
ollamaMessages.push({
role: "system",
content: systemPrompt
});
}
// Track tool names for tool results
const toolNameMap = new Map();
for (const msg of claudeMessages) {
if (msg.role === 'user') {
// Check if it's a tool result message
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'tool_result') {
const toolName = toolNameMap.get(block.tool_use_id) || 'unknown';
ollamaMessages.push(claudeToolResultToOllama(block, toolName));
} else if (block.type === 'text') {
ollamaMessages.push({
role: "user",
content: block.text
});
}
}
} else {
ollamaMessages.push({
role: "user",
content: msg.content
});
}
} else if (msg.role === 'assistant') {
// Handle assistant messages with potential tool calls
if (Array.isArray(msg.content)) {
let textContent = '';
const toolCalls = [];
for (const block of msg.content) {
if (block.type === 'text') {
textContent += block.text;
} else if (block.type === 'tool_use') {
// Track tool name for later tool results
toolNameMap.set(block.id, block.name);
toolCalls.push({
function: {
name: block.name,
// Ollama expects arguments as object, not string
arguments: block.input || {}
}
});
}
}
const assistantMsg = {
role: "assistant",
content: textContent || ""
};
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls;
}
ollamaMessages.push(assistantMsg);
} else {
ollamaMessages.push({
role: "assistant",
content: msg.content
});
}
}
}
return ollamaMessages;
}
/**
* Convert Ollama response to Claude format
*
* @param {object} ollamaResponse - Response from Ollama API
* @returns {object} Response in Claude format
*/
export function ollamaResponseToClaude(ollamaResponse) {
const content = [];
const message = ollamaResponse.message;
// Add text content if present
if (message.content) {
content.push({
type: "text",
text: message.content
});
}
// Add tool calls if present
if (message.tool_calls && message.tool_calls.length > 0) {
for (let i = 0; i < message.tool_calls.length; i++) {
content.push(ollamaToolCallToClaude(message.tool_calls[i], i));
}
}
// Determine stop reason
let stopReason = "end_turn";
if (message.tool_calls && message.tool_calls.length > 0) {
stopReason = "tool_use";
} else if (ollamaResponse.done_reason === "length") {
stopReason = "max_tokens";
}
return {
id: `msg_ollama_${Date.now()}`,
type: "message",
role: "assistant",
content: content,
model: ollamaResponse.model,
stop_reason: stopReason,
usage: {
input_tokens: ollamaResponse.prompt_eval_count || 0,
output_tokens: ollamaResponse.eval_count || 0
}
};
}
// ============================================
// Cloudflare Workers AI Converters
// ============================================
/**
* Convert Claude tool definition to Cloudflare format
* Cloudflare uses OpenAI-compatible format
*
* Claude format:
* { name: "...", description: "...", input_schema: { type: "object", properties: {...} } }
*
* Cloudflare format:
* { type: "function", function: { name: "...", description: "...", parameters: {...} } }
*
* @param {object} claudeTool - Tool in Claude format
* @returns {object} Tool in Cloudflare format
*/
export function claudeToolToCloudflare(claudeTool) {
return {
type: "function",
function: {
name: claudeTool.name,
description: claudeTool.description,
parameters: claudeTool.input_schema
}
};
}
/**
* Convert array of Claude tools to Cloudflare format
* @param {Array} claudeTools - Array of Claude tool definitions
* @returns {Array} Array of Cloudflare function definitions
*/
export function claudeToolsToCloudflare(claudeTools) {
if (!claudeTools || !Array.isArray(claudeTools)) {
return [];
}
return claudeTools.map(claudeToolToCloudflare);
}
/**
* Convert Cloudflare tool call to Claude format
*
* Cloudflare format:
* { name: "...", arguments: {...} }
*
* Claude format:
* { type: "tool_use", id: "...", name: "...", input: {...} }
*
* @param {object} cfToolCall - Tool call from Cloudflare response
* @param {number} index - Index for generating unique ID
* @returns {object} Tool call in Claude format
*/
export function cloudflareToolCallToClaude(cfToolCall, index = 0) {
// Parse arguments if it's a string
let input = cfToolCall.arguments;
if (typeof input === 'string') {
try {
input = JSON.parse(input);
} catch (e) {
console.warn('[ToolConverter] Failed to parse Cloudflare tool arguments:', e);
input = {};
}
}
return {
type: "tool_use",
id: `toolu_cf_${Date.now()}_${index}`,
name: cfToolCall.name,
input: input || {}
};
}
/**
* Convert Claude messages array to Cloudflare format
* Cloudflare uses OpenAI-compatible message format
*
* IMPORTANT: Cloudflare Workers AI does NOT support multi-turn tool conversations.
* It crashes with error 3043 when conversation history contains tool_calls or tool results.
* We must strip tool call history and only keep text content from past messages.
*
* @param {Array} claudeMessages - Messages in Claude format
* @param {string} systemPrompt - System prompt to prepend
* @returns {Array} Messages in Cloudflare format
*/
export function claudeMessagesToCloudflare(claudeMessages, systemPrompt) {
const cfMessages = [];
// Add system message if provided
if (systemPrompt) {
cfMessages.push({
role: "system",
content: systemPrompt
});
}
// Cloudflare doesn't support tool call history in native format - convert to text
// so the model knows what tools were called and their results
for (const msg of claudeMessages) {
if (msg.role === 'user') {
if (Array.isArray(msg.content)) {
// Convert tool_result blocks to text summaries
const textParts = [];
for (const block of msg.content) {
if (block.type === 'text') {
textParts.push(block.text);
} else if (block.type === 'tool_result') {
// Convert tool result to readable text so model knows it was executed
textParts.push(`[Tool Result: ${block.content}]`);
}
}
if (textParts.length > 0) {
cfMessages.push({
role: "user",
content: textParts.join('\n')
});
}
} else {
cfMessages.push({
role: "user",
content: msg.content
});
}
} else if (msg.role === 'assistant') {
// For assistant messages, convert tool_use to text descriptions
const textParts = [];
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'text') {
textParts.push(block.text);
} else if (block.type === 'tool_use') {
// Convert tool call to readable text so model knows it called this
const argsStr = JSON.stringify(block.input || {});
textParts.push(`[Called tool: ${block.name}(${argsStr})]`);
}
}
} else {
textParts.push(msg.content || '');
}
// Also handle pre-converted messages that might have tool_calls property
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
const name = tc.function?.name || tc.name || 'unknown';
const args = tc.function?.arguments || tc.arguments || '{}';
textParts.push(`[Called tool: ${name}(${typeof args === 'string' ? args : JSON.stringify(args)})]`);
}
}
const textContent = textParts.filter(t => t).join('\n');
if (textContent) {
cfMessages.push({
role: "assistant",
content: textContent
});
}
} else if (msg.role === 'tool') {
// Convert tool messages to user messages with result text
cfMessages.push({
role: "user",
content: `[Tool Result (${msg.name || 'unknown'}): ${msg.content}]`
});
}
}
return cfMessages;
}
/**
* Try to repair and parse a potentially truncated JSON object
* @param {string} jsonStr - Potentially incomplete JSON string
* @returns {object|null} - Parsed object or null if unparseable
*/
function tryRepairAndParse(jsonStr) {
// First try as-is
try {
return JSON.parse(jsonStr);
} catch (e) {
// Try adding closing brackets
const repairs = [
jsonStr + '}',
jsonStr + '"}',
jsonStr + '}}',
jsonStr + '"}}',
jsonStr + ': null}}',
jsonStr + '": null}}'
];
for (const attempt of repairs) {
try {
const parsed = JSON.parse(attempt);
if (parsed.name) { // Must have a name to be valid
return parsed;
}
} catch (e2) {
// Continue trying
}
}
return null;
}
}
/**
* Parse tool calls from text response
* Handles multiple formats:
* 1. Mistral native: [TOOL_CALLS][{"name": "...", "arguments": {...}}, ...]
* 2. History format: [Called tool: name({args})]
*
* This parser is resilient to truncation - it will extract as many valid tool calls
* as possible even if the JSON is incomplete.
*
* @param {string} text - Text response that may contain embedded tool calls
* @returns {object} - { cleanText: string, toolCalls: array }
*/
function parseTextToolCalls(text) {
if (!text) return { cleanText: '', toolCalls: [] };
const toolCalls = [];
let cleanText = text;
// Format 1: [TOOL_CALLS][...] (Mistral native format)
const toolCallMatch = text.match(/\[TOOL_CALLS\]\s*(\[[\s\S]*)/);
if (toolCallMatch) {
const toolCallsJson = toolCallMatch[1];
// First try normal JSON.parse (for complete responses)
try {
const parsedCalls = JSON.parse(toolCallsJson);
if (Array.isArray(parsedCalls)) {
const validCalls = parsedCalls
.filter(call => call && call.name)
.map(call => ({
name: call.name,
arguments: call.arguments || {}
}));
console.log(`[ToolConverter] Parsed ${validCalls.length} tool calls from [TOOL_CALLS] JSON`);
cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim();
return { cleanText, toolCalls: validCalls };
}
} catch (e) {
console.log('[ToolConverter] [TOOL_CALLS] JSON incomplete, attempting to extract individual tool calls...');
}
// JSON is truncated - extract individual tool calls using regex
const toolCallStarts = [];
const startPattern = /\{"name"\s*:\s*"/g;
let match;
while ((match = startPattern.exec(toolCallsJson)) !== null) {
toolCallStarts.push(match.index);
}
console.log(`[ToolConverter] Found ${toolCallStarts.length} potential tool call starts in [TOOL_CALLS]`);
for (let i = 0; i < toolCallStarts.length; i++) {
const start = toolCallStarts[i];
const end = toolCallStarts[i + 1] || toolCallsJson.length;
let segment = toolCallsJson.substring(start, end).replace(/,\s*$/, '');
const parsed = tryRepairAndParse(segment);
if (parsed && parsed.name) {
toolCalls.push({
name: parsed.name,
arguments: parsed.arguments || {}
});
console.log(`[ToolConverter] Extracted tool call from [TOOL_CALLS]: ${parsed.name}`);
}
}
cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim();
if (toolCalls.length > 0) {
console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [TOOL_CALLS] format`);
return { cleanText, toolCalls };
}
}
// Format 2: [Called tool: name({args})] (history format the model might mimic)
// Match patterns like: [Called tool: create_entity({"shape": "box", ...})]
const calledToolPattern = /\[Called tool:\s*(\w+)\((\{[\s\S]*?\})\)\]/g;
let calledMatch;
const calledToolMatches = [];
while ((calledMatch = calledToolPattern.exec(text)) !== null) {
calledToolMatches.push({
fullMatch: calledMatch[0],
name: calledMatch[1],
argsStr: calledMatch[2]
});
}
if (calledToolMatches.length > 0) {
console.log(`[ToolConverter] Found ${calledToolMatches.length} [Called tool:] format tool calls`);
for (const match of calledToolMatches) {
try {
const args = JSON.parse(match.argsStr);
toolCalls.push({
name: match.name,
arguments: args
});
console.log(`[ToolConverter] Extracted tool call from [Called tool:]: ${match.name}`);
// Remove this match from clean text
cleanText = cleanText.replace(match.fullMatch, '');
} catch (e) {
console.warn(`[ToolConverter] Failed to parse [Called tool:] args for ${match.name}:`, e.message);
// Try to repair the JSON
const repaired = tryRepairAndParse(match.argsStr);
if (repaired) {
toolCalls.push({
name: match.name,
arguments: repaired
});
console.log(`[ToolConverter] Repaired and extracted tool call: ${match.name}`);
cleanText = cleanText.replace(match.fullMatch, '');
}
}
}
cleanText = cleanText.trim();
if (toolCalls.length > 0) {
console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [Called tool:] format`);
return { cleanText, toolCalls };
}
}
// No tool calls found
return { cleanText: text, toolCalls: [] };
}
/**
* Convert Cloudflare response to Claude format
*
* Cloudflare response format:
* {
* result: {
* response: "text output",
* tool_calls: [{ name: "...", arguments: {...} }]
* },
* success: true
* }
*
* Note: Some models (like Mistral) output tool calls as text in format:
* [TOOL_CALLS][{...}]
*
* @param {object} cfResponse - Response from Cloudflare Workers AI API
* @param {string} model - Model name used
* @returns {object} Response in Claude format
*/
export function cloudflareResponseToClaude(cfResponse, model) {
const content = [];
const result = cfResponse.result || cfResponse;
// Get tool calls from proper field or parse from text
let toolCalls = result.tool_calls || [];
let textResponse = result.response || '';
// Log raw response for debugging
console.log(`[ToolConverter] Raw response (first 500 chars): ${textResponse.substring(0, 500)}`);
console.log(`[ToolConverter] Native tool_calls present: ${toolCalls.length}`);
// Check if tool calls are embedded in text response (Mistral format or history format)
if (toolCalls.length === 0 && textResponse) {
console.log(`[ToolConverter] No native tool_calls, parsing text response...`);
const parsed = parseTextToolCalls(textResponse);
console.log(`[ToolConverter] Parsed ${parsed.toolCalls.length} tool calls from text`);
if (parsed.toolCalls.length > 0) {
toolCalls = parsed.toolCalls;
textResponse = parsed.cleanText;
}
}
// Add text content if present (after removing tool calls)
if (textResponse) {
content.push({
type: "text",
text: textResponse
});
}
// Add tool calls if present
if (toolCalls.length > 0) {
for (let i = 0; i < toolCalls.length; i++) {
content.push(cloudflareToolCallToClaude(toolCalls[i], i));
}
}
// Determine stop reason
let stopReason = "end_turn";
if (toolCalls.length > 0) {
stopReason = "tool_use";
}
// Extract usage if available
const usage = {
input_tokens: result.usage?.prompt_tokens || result.usage?.input_tokens || 0,
output_tokens: result.usage?.completion_tokens || result.usage?.output_tokens || 0
};
return {
id: `msg_cf_${Date.now()}`,
type: "message",
role: "assistant",
content: content,
model: model,
stop_reason: stopReason,
usage: usage
};
}
export default {
claudeToolToOllama,
claudeToolsToOllama,
ollamaToolCallToClaude,
claudeToolResultToOllama,
claudeMessagesToOllama,
ollamaResponseToClaude,
// Cloudflare converters
claudeToolToCloudflare,
claudeToolsToCloudflare,
cloudflareToolCallToClaude,
claudeMessagesToCloudflare,
cloudflareResponseToClaude
};

View File

@ -0,0 +1,241 @@
/**
* Token usage tracking and cost estimation service
*/
// Pricing per million tokens (as of Dec 2025)
const MODEL_PRICING = {
// Claude 4.5 models
"claude-opus-4-5-20251101": { input: 5.00, output: 25.00 },
"claude-sonnet-4-5-20250929": { input: 3.00, output: 15.00 },
"claude-haiku-4-5-20251001": { input: 1.00, output: 5.00 },
// Claude 4 models
"claude-opus-4-1-20250805": { input: 15.00, output: 75.00 },
"claude-sonnet-4-20250514": { input: 3.00, output: 15.00 },
// Claude 3.7/3.5 models
"claude-3-7-sonnet-20250219": { input: 3.00, output: 15.00 },
"claude-3-5-sonnet-20241022": { input: 3.00, output: 15.00 },
"claude-3-5-haiku-20241022": { input: 0.80, output: 4.00 },
// Claude 3 models
"claude-3-opus-20240229": { input: 15.00, output: 75.00 },
"claude-3-sonnet-20240229": { input: 3.00, output: 15.00 },
"claude-3-haiku-20240307": { input: 0.25, output: 1.25 },
// Cloudflare Workers AI models (approximate - based on neuron costs)
"@cf/mistralai/mistral-small-3.1-24b-instruct": { input: 0.30, output: 0.30 },
"@hf/nousresearch/hermes-2-pro-mistral-7b": { input: 0.10, output: 0.10 },
"@cf/meta/llama-3.3-70b-instruct-fp8-fast": { input: 0.20, output: 0.20 },
"@cf/meta/llama-3.1-70b-instruct": { input: 0.20, output: 0.20 },
"@cf/meta/llama-3.1-8b-instruct": { input: 0.05, output: 0.05 },
"@cf/deepseek-ai/deepseek-r1-distill-qwen-32b": { input: 0.15, output: 0.15 },
"@cf/qwen/qwen2.5-coder-32b-instruct": { input: 0.15, output: 0.15 },
};
// Cache pricing multipliers
const CACHE_WRITE_MULTIPLIER = 1.25; // 25% more expensive to write cache
const CACHE_READ_MULTIPLIER = 0.10; // 90% cheaper to read from cache
// In-memory storage for usage tracking
const sessionUsage = new Map();
const globalUsage = {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreationTokens: 0,
totalCacheReadTokens: 0,
totalCost: 0,
requestCount: 0,
byModel: {},
startTime: Date.now()
};
/**
* Get pricing for a model, with fallback to sonnet pricing
*/
function getModelPricing(model) {
// Try exact match first
if (MODEL_PRICING[model]) {
return MODEL_PRICING[model];
}
// Try to match by model family
if (model.includes("opus")) {
return MODEL_PRICING["claude-opus-4-5-20251101"];
}
if (model.includes("haiku")) {
return MODEL_PRICING["claude-haiku-4-5-20251001"];
}
// Cloudflare models - default to cheap pricing
if (model.startsWith("@cf/") || model.startsWith("@hf/")) {
return { input: 0.10, output: 0.10 };
}
// Default to sonnet pricing
return MODEL_PRICING["claude-sonnet-4-5-20250929"];
}
/**
* Calculate cost for a request
*/
function calculateCost(model, usage) {
const pricing = getModelPricing(model);
const perMillionDivisor = 1_000_000;
let cost = 0;
// Standard input tokens
const standardInputTokens = (usage.input_tokens || 0) - (usage.cache_read_input_tokens || 0);
cost += (standardInputTokens / perMillionDivisor) * pricing.input;
// Cache read tokens (90% cheaper)
if (usage.cache_read_input_tokens) {
cost += (usage.cache_read_input_tokens / perMillionDivisor) * pricing.input * CACHE_READ_MULTIPLIER;
}
// Cache creation tokens (25% more expensive)
if (usage.cache_creation_input_tokens) {
cost += (usage.cache_creation_input_tokens / perMillionDivisor) * pricing.input * CACHE_WRITE_MULTIPLIER;
}
// Output tokens
cost += ((usage.output_tokens || 0) / perMillionDivisor) * pricing.output;
return cost;
}
/**
* Track usage for a request
* @param {string} sessionId - Session identifier
* @param {string} model - Model used
* @param {object} usage - Token usage from API response
* @param {object} content - Optional input/output content for detailed tracking
* @param {string} content.inputText - User input text
* @param {string} content.outputText - Assistant output text
* @param {array} content.toolCalls - Tool calls made
*/
export function trackUsage(sessionId, model, usage, content = {}) {
if (!usage) return null;
const cost = calculateCost(model, usage);
// Truncate text for storage (keep first 500 chars)
const truncate = (text, maxLen = 500) => {
if (!text) return null;
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text;
};
const usageRecord = {
timestamp: Date.now(),
model,
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
cacheReadTokens: usage.cache_read_input_tokens || 0,
cost,
inputText: truncate(content.inputText),
outputText: truncate(content.outputText),
toolCalls: content.toolCalls || []
};
// Update session usage
if (sessionId) {
if (!sessionUsage.has(sessionId)) {
sessionUsage.set(sessionId, {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreationTokens: 0,
totalCacheReadTokens: 0,
totalCost: 0,
requestCount: 0,
requests: [],
startTime: Date.now()
});
}
const session = sessionUsage.get(sessionId);
session.totalInputTokens += usageRecord.inputTokens;
session.totalOutputTokens += usageRecord.outputTokens;
session.totalCacheCreationTokens += usageRecord.cacheCreationTokens;
session.totalCacheReadTokens += usageRecord.cacheReadTokens;
session.totalCost += cost;
session.requestCount += 1;
session.requests.push(usageRecord);
// Keep only last 100 requests per session to limit memory
if (session.requests.length > 100) {
session.requests.shift();
}
}
// Update global usage
globalUsage.totalInputTokens += usageRecord.inputTokens;
globalUsage.totalOutputTokens += usageRecord.outputTokens;
globalUsage.totalCacheCreationTokens += usageRecord.cacheCreationTokens;
globalUsage.totalCacheReadTokens += usageRecord.cacheReadTokens;
globalUsage.totalCost += cost;
globalUsage.requestCount += 1;
// Track by model
if (!globalUsage.byModel[model]) {
globalUsage.byModel[model] = {
inputTokens: 0,
outputTokens: 0,
cost: 0,
requestCount: 0
};
}
globalUsage.byModel[model].inputTokens += usageRecord.inputTokens;
globalUsage.byModel[model].outputTokens += usageRecord.outputTokens;
globalUsage.byModel[model].cost += cost;
globalUsage.byModel[model].requestCount += 1;
return usageRecord;
}
/**
* Get usage for a session
*/
export function getSessionUsage(sessionId) {
return sessionUsage.get(sessionId) || null;
}
/**
* Get global usage stats
*/
export function getGlobalUsage() {
return {
...globalUsage,
uptime: Date.now() - globalUsage.startTime
};
}
/**
* Format cost as currency string
*/
export function formatCost(cost) {
return `$${cost.toFixed(6)}`;
}
/**
* Clear session usage (call when session ends)
*/
export function clearSessionUsage(sessionId) {
sessionUsage.delete(sessionId);
}
/**
* Get a formatted usage summary for logging
*/
export function getUsageSummary(usageRecord) {
if (!usageRecord) return "No usage data";
return [
`Input: ${usageRecord.inputTokens}`,
`Output: ${usageRecord.outputTokens}`,
usageRecord.cacheReadTokens ? `Cache read: ${usageRecord.cacheReadTokens}` : null,
usageRecord.cacheCreationTokens ? `Cache write: ${usageRecord.cacheCreationTokens}` : null,
`Cost: ${formatCost(usageRecord.cost)}`
].filter(Boolean).join(" | ");
}

144
src/content/upgradeCopy.ts Normal file
View File

@ -0,0 +1,144 @@
/**
* Copy and messaging for upgrade paths and guest limitations
*/
export interface UpgradeBenefit {
title: string;
description: string;
icon?: string;
}
export const GUEST_LIMITATIONS = {
diagrams: {
limit: 3,
message: 'Guest mode is limited to 3 diagrams',
upgradeMessage: 'Sign up for unlimited diagrams',
},
storage: {
limit: 50, // MB
message: 'Guest mode uses 50MB local storage',
upgradeMessage: 'Get cloud storage with sync',
},
collaboration: {
message: 'Collaboration features are disabled in guest mode',
upgradeMessage: 'Sign up to collaborate with your team',
},
sync: {
message: 'Changes are stored locally only',
upgradeMessage: 'Sign up to sync across all your devices',
},
templates: {
message: 'Templates are not available in guest mode',
upgradeMessage: 'Sign up to access our template library',
},
};
export const UPGRADE_BENEFITS: UpgradeBenefit[] = [
{
title: 'Unlimited Diagrams',
description: 'Create as many diagrams as you need without limits',
},
{
title: 'Cloud Sync',
description: 'Access your work from desktop, VR headset, and any device',
},
{
title: 'Real-Time Collaboration',
description: 'Work together with your team in the same 3D space',
},
{
title: 'Template Library',
description: 'Jump-start your projects with pre-built templates',
},
{
title: 'Secure Cloud Storage',
description: 'Your diagrams safely backed up and encrypted',
},
{
title: 'Priority Support',
description: 'Get help when you need it from our support team',
},
];
export const GUEST_MODE_BANNER = {
title: 'You\'re in Guest Mode',
message: 'Your diagrams are saved locally. Sign up to sync across devices and collaborate with teams.',
ctaText: 'Sign Up Free',
};
export const UPGRADE_CTA = {
hero: {
title: 'Ready to unlock the full experience?',
subtitle: 'Sign up free to sync across devices, collaborate with teams, and create unlimited diagrams.',
primaryCta: 'Sign Up Free',
secondaryCta: 'Learn More',
},
inline: {
title: 'Want more?',
message: 'Sign up to unlock unlimited diagrams, cloud sync, and collaboration.',
ctaText: 'Sign Up',
},
limit: {
diagrams: {
title: 'Diagram Limit Reached',
message: 'You\'ve created 3 diagrams (guest limit). Sign up to create unlimited diagrams.',
ctaText: 'Upgrade Now',
},
},
};
/**
* Get the appropriate upgrade message based on context
*/
export function getUpgradeMessage(context: 'diagram-limit' | 'collaboration' | 'sync' | 'template'): {
title: string;
message: string;
benefits: string[];
} {
switch (context) {
case 'diagram-limit':
return {
title: 'Unlock Unlimited Diagrams',
message: 'Guest mode is limited to 3 diagrams. Sign up to create as many as you need.',
benefits: [
'Create unlimited diagrams',
'Cloud storage and backup',
'Access from any device',
'Real-time collaboration',
],
};
case 'collaboration':
return {
title: 'Collaborate in Real-Time',
message: 'Work together with your team in shared 3D space.',
benefits: [
'Invite unlimited collaborators',
'See changes in real-time',
'Meet as avatars in VR',
'Audit trail of all changes',
],
};
case 'sync':
return {
title: 'Sync Across All Devices',
message: 'Access your diagrams from desktop, VR, and mobile.',
benefits: [
'Cloud sync across devices',
'Work on Quest and desktop',
'Automatic backups',
'Secure cloud storage',
],
};
case 'template':
return {
title: 'Get Started Faster',
message: 'Access pre-built templates for common use cases.',
benefits: [
'Professional templates',
'Org charts and workflows',
'Architecture diagrams',
'Customizable examples',
],
};
}
}

View File

@ -7,36 +7,40 @@ import {
WebXRInputSource
} from "@babylonjs/core";
import {DiagramManager} from "../diagram/diagramManager";
import {DiagramEvent, DiagramEventType} from "../diagram/types/diagramEntity";
import log from "loglevel";
import {ControllerEventType, Controllers} from "./controllers";
import {grabAndClone} from "./functions/grabAndClone";
import {ClickMenu} from "../menus/clickMenu";
import {motionControllerObserver} from "./functions/motionControllerObserver";
import {motionControllerInitObserver} from "./functions/motionControllerInitObserver";
import {DefaultScene} from "../defaultScene";
import {DiagramEventObserverMask} from "../diagram/types/diagramEventObserverMask";
import {DiagramObject} from "../diagram/diagramObject";
import {snapAll} from "./functions/snapAll";
import {MeshTypeEnum} from "../diagram/types/meshTypeEnum";
import {getMeshType} from "./functions/getMeshType";
import {viewOnly} from "../util/functions/getPath";
import {ControllerEventType} from "./types/controllerEventType";
import {controllerObservable} from "./controllers";
import {grabMesh} from "../diagram/functions/grabMesh";
import {dropMesh} from "../diagram/functions/dropMesh";
const CLICK_TIME = 300;
export class Base {
export abstract class AbstractController {
static stickVector = Vector3.Zero();
protected readonly scene: Scene;
protected readonly xr: WebXRDefaultExperience;
protected readonly diagramManager: DiagramManager;
protected xrInputSource: WebXRInputSource;
protected speedFactor = 4;
protected grabbedObject: DiagramObject = null;
protected grabbedMesh: AbstractMesh = null;
protected grabbedMeshType: MeshTypeEnum = null;
protected controllers: Controllers;
private readonly _logger = log.getLogger('Base');
private readonly _logger = log.getLogger('AbstractController');
private _clickStart: number = 0;
private _clickMenu: ClickMenu;
private _pickPoint: Vector3 = new Vector3();
@ -48,7 +52,6 @@ export class Base {
diagramManager: DiagramManager) {
this._logger.debug('Base Controller Constructor called');
this.xrInputSource = controller;
this.controllers = diagramManager.controllers;
this.scene = DefaultScene.Scene;
this.xr = xr;
@ -65,8 +68,8 @@ export class Base {
this.diagramManager = diagramManager;
//@TODO THis works, but it uses initGrip, not sure if this is the best idea
this.xrInputSource.onMotionControllerInitObservable.add(motionControllerObserver, -1, false, this);
this.controllers.controllerObservable.add((event) => {
this.xrInputSource.onMotionControllerInitObservable.add(motionControllerInitObserver, -1, false, this);
controllerObservable.add((event) => {
this._logger.debug(event);
switch (event.type) {
case ControllerEventType.PULSE:
@ -107,10 +110,6 @@ export class Base {
}
if (trigger.changes.pressed) {
if (trigger.pressed) {
if (this.diagramManager.diagramMenuManager.scaleMenu.mesh == this._meshUnderPointer) {
return;
}
if (this._clickStart == 0) {
this._clickStart = Date.now();
window.setTimeout(() => {
@ -141,97 +140,33 @@ export class Base {
}, -1, false, this);
}
private grab() {
let mesh = this._meshUnderPointer
if (!mesh || viewOnly()) {
return;
}
this.grabbedMesh = mesh;
this.grabbedMeshType = getMeshType(mesh, this.diagramManager);
//displayDebug(mesh);
this._logger.debug("grabbing " + mesh.id + " type " + this.grabbedMeshType);
switch (this.grabbedMeshType) {
case MeshTypeEnum.ENTITY:
const diagramObject = this.diagramManager.getDiagramObject(mesh.id);
if (diagramObject.isGrabbable) {
diagramObject.baseTransform.setParent(this.xrInputSource.motionController.rootMesh);
diagramObject.grabbed = true;
this.grabbedObject = diagramObject;
}
break;
case MeshTypeEnum.HANDLE:
this.grabbedMesh.setParent(this.xrInputSource.motionController.rootMesh);
break;
case MeshTypeEnum.TOOL:
const clone = grabAndClone(this.diagramManager, mesh, this.xrInputSource.motionController.rootMesh);
this.grabbedObject = clone;
this.grabbedMesh = clone.mesh;
clone.grabbed = true;
protected notifyObserver(value: number, controllerEventType: ControllerEventType): number {
if (Math.abs(value) > .1) {
controllerObservable.notifyObservers({
type: controllerEventType,
value: value * this.speedFactor
});
return 1;
} else {
return 0;
}
}
private drop() {
const mesh = this.grabbedMesh;
if (!mesh) {
return;
protected initButton(button: WebXRControllerComponent, type: ControllerEventType) {
if (button) {
button.onButtonStateChangedObservable.add((value) => {
if (value.pressed) {
this._logger.debug(button.type, button.id, 'pressed');
controllerObservable.notifyObservers({type: type});
}
const diagramObject = this.grabbedObject;
switch (this.grabbedMeshType) {
case MeshTypeEnum.ENTITY:
if (diagramObject) {
diagramObject.baseTransform.setParent(null);
snapAll(this.grabbedObject.baseTransform, this.diagramManager.config, this._pickPoint);
diagramObject.mesh.computeWorldMatrix(true);
const event: DiagramEvent =
{
type: DiagramEventType.DROP,
entity: diagramObject.diagramEntity
}
this.diagramManager.onDiagramEventObservable.notifyObservers(event, DiagramEventObserverMask.ALL);
diagramObject.mesh.computeWorldMatrix(false);
diagramObject.grabbed = false;
}
this.grabbedObject = null;
this.grabbedMesh = null;
this.grabbedMeshType = null;
break;
case MeshTypeEnum.TOOL:
this.grabbedObject.baseTransform.setParent(null);
snapAll(this.grabbedObject.baseTransform, this.diagramManager.config, this._pickPoint);
diagramObject.mesh.computeWorldMatrix(true);
const event: DiagramEvent =
{
type: DiagramEventType.DROP,
entity: diagramObject.diagramEntity
}
this.diagramManager.onDiagramEventObservable.notifyObservers(event, DiagramEventObserverMask.ALL);
diagramObject.mesh.computeWorldMatrix(false);
this.grabbedObject.grabbed = false;
this.grabbedObject = null;
this.grabbedMesh = null;
this.grabbedMeshType = null;
break;
case MeshTypeEnum.HANDLE:
mesh.setParent(this.scene.getMeshByName("platform"));
const location = {
position: {x: mesh.position.x, y: mesh.position.y, z: mesh.position.z},
rotation: {x: mesh.rotation.x, y: mesh.rotation.y, z: mesh.rotation.z}
}
localStorage.setItem(mesh.id, JSON.stringify(location));
this.grabbedMesh = null;
this.grabbedMeshType = null;
this.grabbedObject = null;
break;
});
}
}
private click() {
let mesh = this.xr.pointerSelection.getMeshUnderPointer(this.xrInputSource.uniqueId);
if (this.diagramManager.isDiagramObject(mesh)) {
if (mesh && this.diagramManager.isDiagramObject(mesh)) {
this._logger.debug("click on " + mesh.id);
if (this.diagramManager.diagramMenuManager.connectionPreview) {
this.diagramManager.diagramMenuManager.connect(mesh);
@ -251,11 +186,37 @@ export class Base {
grip.onButtonStateChangedObservable.add(() => {
if (grip.changes.pressed) {
if (grip.pressed) {
this._logger.debug("=== SQUEEZE PRESSED ===");
this.grab();
} else {
this._logger.debug("=== SQUEEZE RELEASED ===");
this.drop();
}
}
});
}
private grab() {
if (viewOnly() || this._meshUnderPointer == null) {
return;
}
const {
grabbedMesh,
grabbedObject,
grabbedMeshType
} = grabMesh(this._meshUnderPointer, this.diagramManager, this.xrInputSource.motionController.rootMesh);
this.grabbedMesh = grabbedMesh;
this.grabbedObject = grabbedObject;
this.grabbedMeshType = grabbedMeshType;
}
private drop() {
const dropped = dropMesh(this.grabbedMesh, this.grabbedObject, this._pickPoint, this.grabbedMeshType, this.diagramManager);
if (dropped) {
this.grabbedMesh = null;
this.grabbedObject = null;
this.grabbedMeshType = null;
}
}
}

View File

@ -1,41 +1,6 @@
import {AbstractMesh, Observable, TransformNode, Vector3, WebXRInputSource} from "@babylonjs/core";
import {AbstractMesh, Observable, TransformNode} from "@babylonjs/core";
import {ControllerEvent} from "./types/controllerEvent";
export type ControllerEvent = {
type: ControllerEventType,
value?: number,
startPosition?: Vector3,
endPosition?: Vector3,
duration?: number,
gripId?: string;
controller?: WebXRInputSource;
}
export enum ControllerEventType {
GRIP = 'grip',
HIDE = 'hide',
SHOW = 'show',
PULSE = 'pulse',
SQUEEZE = 'squeeze',
CLICK = 'click',
Y_BUTTON = 'y-button',
X_BUTTON = 'x-button',
A_BUTTON = 'a-button',
B_BUTTON = 'b-button',
THUMBSTICK = 'thumbstick',
THUMBSTICK_CHANGED = 'thumbstickChanged',
DECREASE_VELOCITY = 'decreaseVelocity',
INCREASE_VELOCITY = 'decreaseVelocity',
LEFT_RIGHT = 'leftright',
FORWARD_BACK = 'forwardback',
TURN = 'turn',
UP_DOWN = 'updown',
TRIGGER = 'trigger',
MENU = 'menu',
MOTION = 'motion',
GAZEPOINT = 'gazepoint',
}
export class Controllers {
public movable: TransformNode | AbstractMesh;
public readonly controllerObservable: Observable<ControllerEvent> = new Observable();
}
export var movable: TransformNode | AbstractMesh;
export const controllerObservable: Observable<ControllerEvent> = new Observable();

View File

@ -1,25 +0,0 @@
import {HavokPlugin} from "@babylonjs/core";
import {DefaultScene} from "../../defaultScene";
import log from "loglevel";
export function beforeRenderObserver() {
if (this?.grabbedMesh?.physicsBody) {
const scene = DefaultScene.Scene;
const hk = (scene.getPhysicsEngine().getPhysicsPlugin() as HavokPlugin);
this.lastPosition = this?.grabbedMesh?.physicsBody?.transformNode.absolutePosition.clone();
if (this.grabbedMeshParentId) {
const parent = scene.getTransformNodeById(this.grabbedMeshParentId);
if (parent) {
hk.setPhysicsBodyTransformation(this.grabbedMesh.physicsBody, parent);
hk.sync(this.grabbedMesh.physicsBody);
} else {
log.getLogger('beforeRenderObserver').error("parent not found for " + this.grabbedMeshParentId);
}
} else {
log.getLogger('beforeRenderObserver').warn("no parent id");
}
}
}

View File

@ -1,12 +0,0 @@
import {DiagramEvent, DiagramEventType} from "../../diagram/types/diagramEntity";
import {toDiagramEntity} from "../../diagram/functions/toDiagramEntity";
import {AbstractMesh} from "@babylonjs/core";
export function buildDrop(mesh: AbstractMesh): DiagramEvent {
const entity = toDiagramEntity(mesh);
return {
type: DiagramEventType.DROP,
entity: entity
}
}

View File

@ -15,6 +15,7 @@ import {DefaultScene} from "../../defaultScene";
export function buildRig(xr: WebXRDefaultExperience): Mesh {
const scene = DefaultScene.Scene;
const rigMesh = MeshBuilder.CreateCylinder("platform", {diameter: .5, height: .01}, scene);
rigMesh.setAbsolutePosition(new Vector3(0, .01, 5));
const cameratransform = new TransformNode("cameraTransform", scene);
cameratransform.parent = rigMesh;
xr.baseExperience.onInitialXRPoseSetObservable.add(() => {
@ -24,19 +25,24 @@ export function buildRig(xr: WebXRDefaultExperience): Mesh {
});
for (const cam of scene.cameras) {
cam.parent = cameratransform;
cam.position = new Vector3(0, 1.6, 0); // Reset to local position above platform
}
scene.onActiveCameraChanged.add(() => {
for (const cam of scene.cameras) {
cam.parent = cameratransform;
cam.position = new Vector3(0, 1.6, 0); // Reset to local position above platform
}
});
rigMesh.setAbsolutePosition(new Vector3(0, .01, 5));
rigMesh.isPickable = false;
const axis = new AxesViewer(scene, .25);
axis.zAxis.rotation.y = Math.PI;
rigMesh.lookAt(new Vector3(0, 0.01, 0));
rigMesh.visibility = 1;
// Only create physics aggregate if physics engine is available
if (scene.getPhysicsEngine()) {
const rigAggregate =
new PhysicsAggregate(
rigMesh,
@ -44,5 +50,20 @@ export function buildRig(xr: WebXRDefaultExperience): Mesh {
{friction: 0, center: Vector3.Zero(), mass: 50, restitution: .01},
scene);
rigAggregate.body.setMotionType(PhysicsMotionType.DYNAMIC);
} else {
// Add physics aggregate once physics is initialized
scene.onReadyObservable.addOnce(() => {
if (scene.getPhysicsEngine()) {
const rigAggregate =
new PhysicsAggregate(
rigMesh,
PhysicsShapeType.CYLINDER,
{friction: 0, center: Vector3.Zero(), mass: 50, restitution: .01},
scene);
rigAggregate.body.setMotionType(PhysicsMotionType.DYNAMIC);
}
});
}
return rigMesh;
}

View File

@ -3,6 +3,8 @@ import {DiagramManager} from "../../diagram/diagramManager";
import {DiagramObject} from "../../diagram/diagramObject";
import log from "loglevel";
import {vectoxys} from "../../diagram/functions/vectorConversion";
import {DiagramEntityType} from "../../diagram/types/diagramEntity";
import {DefaultScene} from "../../defaultScene";
export function grabAndClone(diagramManager: DiagramManager, mesh: AbstractMesh, parent: AbstractMesh):
DiagramObject {
@ -23,10 +25,11 @@ export function grabAndClone(diagramManager: DiagramManager, mesh: AbstractMesh,
color: mesh.metadata.color,
position: vectoxys(mesh.absolutePosition),
rotation: vectoxys(mesh.absoluteRotationQuaternion.toEulerAngles()),
scale: vectoxys(mesh.scaling)
scale: vectoxys(mesh.scaling),
type: DiagramEntityType.ENTITY
}
const obj = new DiagramObject(parent.getScene(),
const obj = new DiagramObject(DefaultScene.Scene,
diagramManager.onDiagramEventObservable,
{
diagramEntity: entity,
@ -35,7 +38,5 @@ export function grabAndClone(diagramManager: DiagramManager, mesh: AbstractMesh,
obj.baseTransform.setParent(parent);
diagramManager.addObject(obj);
return obj;
}
}

View File

@ -8,7 +8,6 @@ export function handleWasGrabbed(mesh: AbstractMesh): boolean {
logger.debug("handleWasGrabbed: mesh is a diagram entity");
return false;
} else {
const result = (mesh?.metadata?.handle == true);
logger.debug("handleWasGrabbed: mesh ", result);
return result;

View File

@ -1,7 +1,7 @@
import log from "loglevel";
export function motionControllerObserver(init) {
export function motionControllerInitObserver(init) {
const logger = log.getLogger('motionControllerObserver');
logger.debug(init.components);
if (init.components['xr-standard-squeeze']) {

View File

@ -1,24 +0,0 @@
import {AbstractMesh} from "@babylonjs/core";
import log from "loglevel";
export function reparent(mesh: AbstractMesh, previousParentId: string, grabbedMeshParentId: string) {
const logger = log.getLogger('reparent');
if (previousParentId) {
const parent = mesh.getScene().getMeshById(previousParentId);
if (parent) {
logger.warn('not yet implemented')
} else {
mesh.setParent(null);
}
} else {
const parent = mesh.getScene().getTransformNodeById(grabbedMeshParentId);
if (parent) {
logger.warn('setting parent to null', grabbedMeshParentId, parent)
mesh.setParent(null);
parent.dispose();
} else {
mesh.setParent(null);
}
}
}

View File

@ -1,10 +0,0 @@
import {AbstractMesh, TransformNode} from "@babylonjs/core";
export function setupTransformNode(mesh: TransformNode, parent: AbstractMesh) {
const transformNode = new TransformNode("grabAnchor, this.scene");
transformNode.id = "grabAnchor";
transformNode.position = mesh.position.clone();
transformNode.rotationQuaternion = mesh.rotationQuaternion.clone();
transformNode.setParent(parent);
return transformNode;
}

View File

@ -1,15 +1,24 @@
import {TransformNode, Vector3} from "@babylonjs/core";
import {AppConfig} from "../../util/appConfig";
import {appConfigInstance} from "../../util/appConfig";
import {snapRotateVal} from "../../util/functions/snapRotateVal";
import {snapGridVal} from "../../util/functions/snapGridVal";
export function snapAll(node: TransformNode, config: AppConfig, pickPoint: Vector3) {
export function snapAll(node: TransformNode, pickPoint: Vector3) {
const config = appConfigInstance.current;
const transform = new TransformNode('temp', node.getScene());
transform.position = pickPoint;
node.setParent(transform);
node.rotation = snapRotateVal(node.absoluteRotationQuaternion.toEulerAngles(), config.current.rotateSnap);
transform.position = snapGridVal(transform.absolutePosition, config.current.gridSnap);
if (config.rotateSnap > 0) {
node.rotation = snapRotateVal(node.absoluteRotationQuaternion.toEulerAngles(), config.rotateSnap);
}
if (config.locationSnap > 0) {
transform.position = snapGridVal(transform.absolutePosition, config.locationSnap);
}
node.setParent(null);
node.position = snapGridVal(node.absolutePosition, config.current.gridSnap);
if (config.locationSnap > 0) {
node.position = snapGridVal(node.absolutePosition, config.locationSnap);
}
transform.dispose();
}

View File

@ -1,23 +1,22 @@
import {Vector3, WebXRControllerComponent, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
import {Base} from "./base";
import {ControllerEventType} from "./controllers";
import {AbstractController} from "./abstractController";
import log from "loglevel";
import {DiagramManager} from "../diagram/diagramManager";
import {DefaultScene} from "../defaultScene";
import {ControllerEventType} from "./types/controllerEventType";
import {controllerObservable, movable} from "./controllers";
export class Left extends Base {
export class LeftController extends AbstractController {
private leftLogger = log.getLogger('Left');
constructor(controller:
WebXRInputSource, xr: WebXRDefaultExperience, diagramManager: DiagramManager) {
super(controller, xr, diagramManager);
const scene = DefaultScene.Scene;
this.xrInputSource.onMotionControllerInitObservable.add((init) => {
if (init.components['xr-standard-thumbstick']) {
init.components['xr-standard-thumbstick']
.onAxisValueChangedObservable.add((value) => {
this.leftLogger.trace(`thumbstick moved ${value.x}, ${value.y}`);
if (!this.controllers.movable) {
if (!movable) {
this.moveRig(value);
} else {
this.moveMovable(value);
@ -29,7 +28,7 @@ export class Left extends Base {
init.components['xr-standard-thumbstick'].onButtonStateChangedObservable.add((value) => {
if (value.pressed) {
this.leftLogger.trace('Left', 'thumbstick changed');
this.controllers.controllerObservable.notifyObservers({
controllerObservable.notifyObservers({
type: ControllerEventType.DECREASE_VELOCITY,
value: value.value
});
@ -46,7 +45,7 @@ export class Left extends Base {
.onButtonStateChangedObservable
.add((button) => {
this.leftLogger.trace('trigger pressed');
this.controllers.controllerObservable.notifyObservers({
controllerObservable.notifyObservers({
type: ControllerEventType.TRIGGER,
value: button.value,
controller: this.xrInputSource
@ -60,7 +59,7 @@ export class Left extends Base {
xbutton.onButtonStateChangedObservable.add((button) => {
if (button.pressed) {
this.leftLogger.trace('X button pressed');
this.controllers.controllerObservable.notifyObservers({
controllerObservable.notifyObservers({
type: ControllerEventType.X_BUTTON,
value: button.value
});
@ -74,7 +73,7 @@ export class Left extends Base {
ybutton.onButtonStateChangedObservable.add((button) => {
if (button.pressed) {
this.leftLogger.trace('Y button pressed');
this.controllers.controllerObservable.notifyObservers({
controllerObservable.notifyObservers({
type: ControllerEventType.Y_BUTTON,
value: button.value
});
@ -85,42 +84,23 @@ export class Left extends Base {
private moveMovable(value: { x: number, y: number }) {
if (Math.abs(value.x) > .1) {
this.controllers.movable.position.x += .005 * Math.sign(value.x);
movable.position.x += .005 * Math.sign(value.x);
} else {
}
if (Math.abs(value.y) > .1) {
this.controllers.movable.position.y += -.005 * Math.sign(value.y);
movable.position.y += -.005 * Math.sign(value.y);
} else {
}
}
private moveRig(value: { x: number, y: number }) {
if (Math.abs(value.x) > .1) {
this.controllers.controllerObservable.notifyObservers({
type: ControllerEventType.LEFT_RIGHT,
value: value.x * this.speedFactor
});
Base.stickVector.x = 1;
} else {
Base.stickVector.x = 0;
}
if (Math.abs(value.y) > .1) {
this.controllers.controllerObservable.notifyObservers({
type: ControllerEventType.FORWARD_BACK,
value: value.y * this.speedFactor
});
Base.stickVector.y = 1;
} else {
Base.stickVector.y = 0;
}
if (Base.stickVector.equals(Vector3.Zero())) {
this.controllers.controllerObservable.notifyObservers({type: ControllerEventType.LEFT_RIGHT, value: 0});
this.controllers.controllerObservable.notifyObservers({type: ControllerEventType.FORWARD_BACK, value: 0});
} else {
AbstractController.stickVector.x = this.notifyObserver(value.x, ControllerEventType.LEFT_RIGHT);
AbstractController.stickVector.y = this.notifyObserver(value.y, ControllerEventType.FORWARD_BACK);
if (AbstractController.stickVector.equals(Vector3.Zero())) {
controllerObservable.notifyObservers({type: ControllerEventType.LEFT_RIGHT, value: 0});
controllerObservable.notifyObservers({type: ControllerEventType.FORWARD_BACK, value: 0});
}
}
}

View File

@ -1,102 +0,0 @@
import {Base} from "./base";
import {Vector3, WebXRControllerComponent, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
import {ControllerEventType} from "./controllers";
import {DiagramManager} from "../diagram/diagramManager";
import log from "loglevel";
export class Right extends Base {
private rightLogger = log.getLogger("Right");
private initBButton(bbutton: WebXRControllerComponent) {
if (bbutton) {
bbutton.onButtonStateChangedObservable.add((button) => {
if (button.pressed) {
this.rightLogger.debug('B Button Pressed');
this.controllers.controllerObservable.notifyObservers({
type: ControllerEventType.B_BUTTON,
value: button.value
});
}
});
}
}
constructor(controller: WebXRInputSource,
xr: WebXRDefaultExperience,
diagramManager: DiagramManager
) {
super(controller, xr, diagramManager);
this.xrInputSource.onMotionControllerInitObservable.add((init) => {
this.initTrigger(init.components['xr-standard-trigger']);
this.initBButton(init.components['b-button']);
this.initAButton(init.components['a-button']);
this.initThumbstick(init.components['xr-standard-thumbstick']);
});
}
private initTrigger(trigger: WebXRControllerComponent) {
if (trigger) {
trigger
.onButtonStateChangedObservable
.add((button) => {
this.rightLogger.debug("right trigger pressed");
this.controllers.controllerObservable.notifyObservers({
type: ControllerEventType.TRIGGER,
value: button.value,
controller: this.xrInputSource
});
}, -1, false, this);
}
}
private initAButton(abutton: WebXRControllerComponent) {
if (abutton) {
abutton.onButtonStateChangedObservable.add((value) => {
if (value.pressed) {
this.rightLogger.debug('A button pressed');
this.controllers.controllerObservable.notifyObservers({type: ControllerEventType.MENU});
}
});
}
}
private initThumbstick(thumbstick: WebXRControllerComponent) {
if (thumbstick) {
thumbstick.onAxisValueChangedObservable.add((value) => {
this.rightLogger.trace(`thumbstick moved ${value.x}, ${value.y}`);
this.moveRig(value);
});
thumbstick.onButtonStateChangedObservable.add((value) => {
if (value.pressed) {
this.rightLogger.trace('Right', `thumbstick changed ${value.value}`);
this.controllers.controllerObservable.notifyObservers({
type: ControllerEventType.INCREASE_VELOCITY,
value: value.value
});
}
});
}
}
private moveRig(value) {
if (Math.abs(value.x) > .1) {
this.controllers.controllerObservable.notifyObservers({type: ControllerEventType.TURN, value: value.x});
} else {
this.controllers.controllerObservable.notifyObservers({type: ControllerEventType.TURN, value: 0});
}
if (Math.abs(value.y) > .1) {
this.controllers.controllerObservable.notifyObservers({
type: ControllerEventType.UP_DOWN,
value: value.y * this.speedFactor
});
Base.stickVector.z = 1;
} else {
this.controllers.controllerObservable.notifyObservers({type: ControllerEventType.UP_DOWN, value: 0});
Base.stickVector.z = 0;
}
if (Base.stickVector.equals(Vector3.Zero())) {
this.controllers.controllerObservable.notifyObservers({type: ControllerEventType.UP_DOWN, value: 0});
}
}
}

View File

@ -0,0 +1,73 @@
import {AbstractController} from "./abstractController";
import {Vector3, WebXRControllerComponent, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
import {DiagramManager} from "../diagram/diagramManager";
import log from "loglevel";
import {ControllerEventType} from "./types/controllerEventType";
import {controllerObservable} from "./controllers";
export class RightController extends AbstractController {
private rightLogger = log.getLogger("Right");
constructor(controller: WebXRInputSource,
xr: WebXRDefaultExperience,
diagramManager: DiagramManager
) {
super(controller, xr, diagramManager);
this.xrInputSource.onMotionControllerInitObservable.add((init) => {
this.initTrigger(init.components['xr-standard-trigger']);
this.initButton(init.components['b-button'], ControllerEventType.B_BUTTON);
this.initButton(init.components['a-button'], ControllerEventType.MENU);
this.initThumbstick(init.components['xr-standard-thumbstick']);
});
}
private initTrigger(trigger: WebXRControllerComponent) {
if (trigger) {
trigger
.onButtonStateChangedObservable
.add((button) => {
this.rightLogger.debug("right trigger pressed");
controllerObservable.notifyObservers({
type: ControllerEventType.TRIGGER,
value: button.value,
controller: this.xrInputSource
});
}, -1, false, this);
}
}
private initThumbstick(thumbstick: WebXRControllerComponent) {
if (thumbstick) {
thumbstick.onAxisValueChangedObservable.add((value) => {
this.rightLogger.trace(`thumbstick moved ${value.x}, ${value.y}`);
this.moveRig(value);
});
thumbstick.onButtonStateChangedObservable.add((value) => {
if (value.pressed) {
this.rightLogger.trace('Right', `thumbstick changed ${value.value}`);
controllerObservable.notifyObservers({
type: ControllerEventType.INCREASE_VELOCITY,
value: value.value
});
}
});
}
}
private moveRig(value) {
if (Math.abs(value.x) > .1) {
controllerObservable.notifyObservers({type: ControllerEventType.TURN, value: value.x});
} else {
controllerObservable.notifyObservers({type: ControllerEventType.TURN, value: 0});
}
AbstractController.stickVector.z = this.notifyObserver(value.y, ControllerEventType.UP_DOWN);
if (AbstractController.stickVector.equals(Vector3.Zero())) {
controllerObservable.notifyObservers({type: ControllerEventType.UP_DOWN, value: 0});
}
}
}

View File

@ -1,17 +1,20 @@
import {Angle, Mesh, Quaternion, Scene, Vector3, WebXRDefaultExperience} from "@babylonjs/core";
import {Right} from "./right";
import {Left} from "./left";
import {ControllerEvent, ControllerEventType, Controllers} from "./controllers";
import {Angle, Mesh, Observer, Quaternion, Scene, Vector3, WebXRDefaultExperience} from "@babylonjs/core";
import {RightController} from "./rightController";
import {LeftController} from "./leftController";
import log from "loglevel";
import {DiagramManager} from "../diagram/diagramManager";
import {buildRig} from "./functions/buildRig";
import {DefaultScene} from "../defaultScene";
import {ControllerEvent} from "./types/controllerEvent";
import {ControllerEventType} from "./types/controllerEventType";
import {controllerObservable} from "./controllers";
import {appConfigInstance} from "../util/appConfig";
import {AppConfigType} from "../util/appConfigType";
const RIGHT = "right";
const LEFT = "left";
export class Rigplatform {
public static instance: Rigplatform;
@ -19,20 +22,21 @@ export class Rigplatform {
public rigMesh: Mesh;
private _logger = log.getLogger('Rigplatform');
private readonly _controllers: Controllers;
private readonly _diagramManager: DiagramManager;
private readonly _scene: Scene;
private readonly _velocityArray = [0.01, 0.1, 1, 2, 5];
private readonly _xr: WebXRDefaultExperience;
private _rightController: Right;
private _leftController: Left;
private _rightController: RightController;
private _leftController: LeftController;
private _turning: boolean = false;
private _velocity: Vector3 = Vector3.Zero();
private _velocityIndex: number = 2;
private _turnVelocity: number = 0;
private _registered = false;
private _yRotation: number = 0;
private _configObserver: Observer<AppConfigType>;
constructor(
xr: WebXRDefaultExperience,
@ -40,12 +44,34 @@ export class Rigplatform {
) {
this._scene = DefaultScene.Scene;
this._diagramManager = diagramManager;
this._controllers = diagramManager.controllers;
this._xr = xr;
this.rigMesh = buildRig(xr);
// Exit XR button is now created in toolbox class
this._fixRotation();
this._initializeControllers();
this._registerVelocityObserver();
this._subscribeToConfigChanges();
}
/**
* Subscribe to config changes to update flyMode and turnSnap at runtime
*/
private _subscribeToConfigChanges(): void {
this._configObserver = appConfigInstance.onConfigChangedObservable.add((config) => {
// Update fly mode if changed
if (config.flyMode !== this._flyMode) {
this.flyMode = config.flyMode;
this._logger.debug('Fly mode updated from config:', config.flyMode);
}
// Update turn snap if changed
if (config.turnSnap !== this.turnSnap) {
this.turnSnap = config.turnSnap;
this._logger.debug('Turn snap updated from config:', config.turnSnap);
}
});
}
private _flyMode: boolean = true;
@ -112,12 +138,12 @@ export class Rigplatform {
private _registerObserver() {
if (this._registered) {
this._logger.warn('observer already registered, clearing and re registering');
this._controllers.controllerObservable.clear();
controllerObservable.clear();
this._registered = false;
}
if (!this._registered) {
this._registered = true;
this._controllers.controllerObservable.add((event: ControllerEvent) => {
controllerObservable.add((event: ControllerEvent) => {
this._logger.debug(event);
switch (event.type) {
case ControllerEventType.INCREASE_VELOCITY:
@ -165,12 +191,12 @@ export class Rigplatform {
switch (source.inputSource.handedness) {
case RIGHT:
if (!this._rightController) {
this._rightController = new Right(source, this._xr, this._diagramManager);
this._rightController = new RightController(source, this._xr, this._diagramManager);
}
break;
case LEFT:
if (!this._leftController) {
this._leftController = new Left(source, this._xr, this._diagramManager);
this._leftController = new LeftController(source, this._xr, this._diagramManager);
}
break;
}
@ -212,4 +238,25 @@ export class Rigplatform {
}
}, -1, false, this, false);
}
/**
* Clean up resources and observers
*/
public dispose(): void {
// Remove config observer
if (this._configObserver) {
appConfigInstance.onConfigChangedObservable.remove(this._configObserver);
this._configObserver = null;
}
// Clean up controllers
if (this._rightController) {
this._rightController = null;
}
if (this._leftController) {
this._leftController = null;
}
this._logger.debug('Rigplatform disposed');
}
}

View File

@ -0,0 +1,12 @@
import {Vector3, WebXRInputSource} from "@babylonjs/core";
import {ControllerEventType} from "./controllerEventType";
export type ControllerEvent = {
type: ControllerEventType,
value?: number,
startPosition?: Vector3,
endPosition?: Vector3,
duration?: number,
gripId?: string;
controller?: WebXRInputSource;
}

View File

@ -0,0 +1,24 @@
export enum ControllerEventType {
GRIP = 'grip',
HIDE = 'hide',
SHOW = 'show',
PULSE = 'pulse',
SQUEEZE = 'squeeze',
CLICK = 'click',
Y_BUTTON = 'y-button',
X_BUTTON = 'x-button',
A_BUTTON = 'a-button',
B_BUTTON = 'b-button',
THUMBSTICK = 'thumbstick',
THUMBSTICK_CHANGED = 'thumbstickChanged',
DECREASE_VELOCITY = 'decreaseVelocity',
INCREASE_VELOCITY = 'decreaseVelocity',
LEFT_RIGHT = 'leftright',
FORWARD_BACK = 'forwardback',
TURN = 'turn',
UP_DOWN = 'updown',
TRIGGER = 'trigger',
MENU = 'menu',
MOTION = 'motion',
GAZEPOINT = 'gazepoint',
}

View File

@ -1,9 +1,12 @@
import {AbstractMesh, KeyboardEventTypes, Scene} from "@babylonjs/core";
import {Rigplatform} from "./rigplatform";
import {Controllers} from "./controllers";
import {DiagramManager} from "../diagram/diagramManager";
import {wheelHandler} from "./functions/wheelHandler";
import log, {Logger} from "loglevel";
import {DiagramEntityType, DiagramEventType, DiagramTemplates} from "../diagram/types/diagramEntity";
import {DiagramEventObserverMask} from "../diagram/types/diagramEventObserverMask";
import {getToolboxColors} from "../toolbox/toolbox";
export class WebController {
private readonly scene: Scene;
@ -12,7 +15,7 @@ export class WebController {
private rig: Rigplatform;
private diagramManager: DiagramManager;
private mouseDown: boolean = false;
private readonly controllers: Controllers;
private upDownWheel: boolean = false;
private fowardBackWheel: boolean = false;
private canvas: HTMLCanvasElement;
@ -20,11 +23,11 @@ export class WebController {
constructor(scene: Scene,
rig: Rigplatform,
diagramManager: DiagramManager,
controllers: Controllers) {
) {
this.scene = scene;
this.rig = rig;
this.diagramManager = diagramManager;
this.controllers = controllers;
this.canvas = document.querySelector('#gameCanvas');
//this.referencePlane = MeshBuilder.CreatePlane('referencePlane', {size: 10}, this.scene);
//this.referencePlane.setEnabled(false);
@ -94,6 +97,18 @@ export class WebController {
*/
break;
case "T":
// Ctrl+Shift+T: Create test entities (sphere and box)
if (kbInfo.event.ctrlKey && kbInfo.event.shiftKey) {
this.createTestEntities();
}
break;
case "X":
// Ctrl+Shift+X: Clear all entities from diagram
if (kbInfo.event.ctrlKey && kbInfo.event.shiftKey) {
this.clearAllEntities();
}
break;
default:
this.logger.debug(kbInfo.event);
@ -160,7 +175,7 @@ export class WebController {
});
this.scene.onPointerDown = (evt, state) => {
this.scene.onPointerDown = (evt) => {
if (evt.pointerType == "mouse") {
this.mouseDown = true;
/*if (evt.shiftKey) {
@ -240,4 +255,57 @@ export class WebController {
}
this._mesh = mesh;
}
/**
* Create test entities for testing ResizeGizmo
* Creates a sphere at (-0.25, 1.5, 4) and a box at (0.25, 1.5, 4)
*/
private createTestEntities(): void {
this.logger.info('Creating test entities (Ctrl+Shift+T)');
// Get first color from toolbox colors array
const firstColor = getToolboxColors()[0];
const colorHex = firstColor.replace('#', '');
// Create sphere
this.diagramManager.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.ADD,
entity: {
id: `test-sphere-${colorHex}`,
type: DiagramEntityType.ENTITY,
template: DiagramTemplates.SPHERE,
position: { x: -0.25, y: 1.5, z: 4 },
scale: { x: 0.1, y: 0.1, z: 0.1 },
color: firstColor
}
}, DiagramEventObserverMask.ALL);
// Create box
this.diagramManager.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.ADD,
entity: {
id: `test-box-${colorHex}`,
type: DiagramEntityType.ENTITY,
template: DiagramTemplates.BOX,
position: { x: 0.25, y: 1.5, z: 4 },
scale: { x: 0.1, y: 0.1, z: 0.1 },
color: firstColor
}
}, DiagramEventObserverMask.ALL);
this.logger.info(`Test entities created with color ${firstColor}: test-sphere-${colorHex} at (-0.25, 1.5, 4) and test-box-${colorHex} at (0.25, 1.5, 4)`);
}
/**
* Clear all entities from the diagram
*/
private clearAllEntities(): void {
this.logger.info('Clearing all entities from diagram (Ctrl+Shift+X)');
this.diagramManager.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.CLEAR
}, DiagramEventObserverMask.TO_DB);
this.logger.info('All entities cleared from diagram');
}
}

View File

@ -4,6 +4,10 @@ import log from "loglevel";
export class DefaultScene {
private static _Scene: Scene;
private static _UtilityScene: Scene;
public static get UtilityScene(): Scene {
return this._UtilityScene;
}
public static get Scene(): Scene {
if (!DefaultScene._Scene) {

View File

@ -1,8 +1,8 @@
import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene} from "@babylonjs/core";
import {DiagramEntity, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, Vector3, WebXRDefaultExperience} from "@babylonjs/core";
import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import log from "loglevel";
import {Controllers} from "../controllers/controllers";
import {AppConfig} from "../util/appConfig";
import {appConfigInstance} from "../util/appConfig";
import {buildEntityActionManager} from "./functions/buildEntityActionManager";
import {DefaultScene} from "../defaultScene";
import {DiagramMenuManager} from "./diagramMenuManager";
@ -11,12 +11,14 @@ import {DiagramObject} from "./diagramObject";
import {getMe} from "../util/me";
import {UserModelType} from "../users/userTypes";
import {vectoxys} from "./functions/vectorConversion";
import {controllerObservable} from "../controllers/controllers";
import {ControllerEvent} from "../controllers/types/controllerEvent";
import {HEX_TO_COLOR_NAME, TEMPLATE_TO_SHAPE} from "../react/types/chatTypes";
export class DiagramManager {
private readonly _logger = log.getLogger('DiagramManager');
public readonly _config: AppConfig;
private readonly _controllers: Controllers;
private readonly _controllerObservable: Observable<ControllerEvent>;
private readonly _diagramEntityActionManager: ActionManager;
public readonly onDiagramEventObservable: Observable<DiagramEvent> = new Observable();
public readonly onUserEventObservable: Observable<UserModelType> = new Observable();
@ -27,16 +29,20 @@ export class DiagramManager {
private _moving: number = 10;
private _i: number = 0;
public get diagramMenuManager(): DiagramMenuManager {
return this._diagramMenuManager;
}
public setXR(xr: WebXRDefaultExperience): void {
this._diagramMenuManager.setXR(xr);
}
constructor(readyObservable: Observable<boolean>) {
this._me = getMe();
this._scene = DefaultScene.Scene;
this._config = new AppConfig();
this._controllers = new Controllers();
this._diagramMenuManager = new DiagramMenuManager(this.onDiagramEventObservable, this._controllers, this._config, readyObservable);
this._diagramEntityActionManager = buildEntityActionManager(this._controllers);
this._diagramMenuManager = new DiagramMenuManager(this.onDiagramEventObservable, controllerObservable, readyObservable);
this._diagramEntityActionManager = buildEntityActionManager(controllerObservable);
this.onDiagramEventObservable.add(this.onDiagramEvent, DiagramEventObserverMask.FROM_DB, true, this);
this.onUserEventObservable.add((user) => {
if (user.id != this._me) {
this._logger.debug('user event', user);
@ -80,6 +86,7 @@ export class DiagramManager {
template: '#image-template',
image: event.detail.data,
text: event.detail.name,
type: DiagramEntityType.ENTITY,
position: {x: 0, y: 1.6, z: 0},
rotation: {x: 0, y: Math.PI, z: 0},
scale: {x: 1, y: 1, z: 1},
@ -96,18 +103,223 @@ export class DiagramManager {
}
});
this._logger.debug("DiagramManager constructed");
// Chat event listeners for AI-powered diagram creation
document.addEventListener('chatCreateEntity', (event: CustomEvent) => {
const {entity} = event.detail;
this._logger.debug('chatCreateEntity', entity);
// Generate a default label if none is provided
if (!entity.text) {
entity.text = this.generateDefaultLabel(entity);
this._logger.debug('Generated default label:', entity.text);
}
const object = new DiagramObject(this._scene, this.onDiagramEventObservable, {
diagramEntity: entity,
actionManager: this._diagramEntityActionManager
});
this._diagramObjects.set(entity.id, object);
this.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.ADD,
entity: entity
}, DiagramEventObserverMask.TO_DB);
});
document.addEventListener('chatRemoveEntity', (event: CustomEvent) => {
const {target} = event.detail;
this._logger.debug('chatRemoveEntity', target);
const entity = this.findEntityByIdOrLabel(target);
if (entity) {
const diagramObject = this._diagramObjects.get(entity.id);
if (diagramObject) {
diagramObject.dispose();
this._diagramObjects.delete(entity.id);
this.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.REMOVE,
entity: entity
}, DiagramEventObserverMask.TO_DB);
}
}
});
document.addEventListener('chatModifyEntity', (event: CustomEvent) => {
const {target, updates} = event.detail;
this._logger.debug('chatModifyEntity', target, updates);
const entity = this.findEntityByIdOrLabel(target);
if (entity) {
const diagramObject = this._diagramObjects.get(entity.id);
if (diagramObject) {
// Apply updates using setters (each setter handles its own DB notification)
if (updates.text !== undefined) {
diagramObject.text = updates.text;
}
if (updates.color !== undefined) {
diagramObject.color = updates.color;
}
if (updates.position !== undefined) {
diagramObject.position = updates.position;
}
if (updates.scale !== undefined) {
diagramObject.scale = updates.scale;
}
if (updates.rotation !== undefined) {
diagramObject.rotation = updates.rotation;
}
}
} else {
this._logger.warn('chatModifyEntity: entity not found:', target);
}
});
document.addEventListener('chatModifyConnection', (event: CustomEvent) => {
const {target, updates} = event.detail;
this._logger.debug('chatModifyConnection', target, updates);
let connection: DiagramEntity | undefined;
// Check if target is a connection:fromId:toId format
if (target.startsWith('connection:')) {
const parts = target.split(':');
if (parts.length === 3) {
const fromId = parts[1];
const toId = parts[2];
// Find connection by from/to
connection = Array.from(this._diagramObjects.values())
.map(obj => obj.diagramEntity)
.find(e => e.template === '#connection-template' && e.from === fromId && e.to === toId);
}
} else {
// Find by label (text)
connection = this.findEntityByIdOrLabel(target);
// Verify it's a connection
if (connection && connection.template !== '#connection-template') {
this._logger.warn('chatModifyConnection: found entity is not a connection:', target);
connection = undefined;
}
}
if (connection) {
const diagramObject = this._diagramObjects.get(connection.id);
if (diagramObject) {
if (updates.text !== undefined) {
diagramObject.text = updates.text;
}
if (updates.color !== undefined) {
diagramObject.color = updates.color;
}
}
} else {
this._logger.warn('chatModifyConnection: connection not found:', target);
}
});
document.addEventListener('chatListEntities', () => {
this._logger.debug('chatListEntities');
const entities = Array.from(this._diagramObjects.values()).map(obj => ({
id: obj.diagramEntity.id,
template: obj.diagramEntity.template,
text: obj.diagramEntity.text || '',
color: obj.diagramEntity.color,
position: obj.diagramEntity.position
}));
const responseEvent = new CustomEvent('chatListEntitiesResponse', {
detail: {entities},
bubbles: true
});
document.dispatchEvent(responseEvent);
});
// Resolve entity label/ID to actual entity ID and label
document.addEventListener('chatResolveEntity', (event: CustomEvent) => {
const {target, requestId} = event.detail;
this._logger.debug('chatResolveEntity', target);
const entity = this.findEntityByIdOrLabel(target);
const responseEvent = new CustomEvent('chatResolveEntityResponse', {
detail: {
requestId,
target,
entityId: entity?.id || null,
entityLabel: entity?.text || null,
found: !!entity
},
bubbles: true
});
document.dispatchEvent(responseEvent);
});
// Clear all entities from the diagram
document.addEventListener('chatClearDiagram', () => {
this._logger.debug('chatClearDiagram - removing all entities');
const entitiesToRemove = Array.from(this._diagramObjects.keys());
for (const id of entitiesToRemove) {
const diagramObject = this._diagramObjects.get(id);
if (diagramObject) {
const entity = diagramObject.diagramEntity;
diagramObject.dispose();
this._diagramObjects.delete(id);
this.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.REMOVE,
entity: entity
}, DiagramEventObserverMask.TO_DB);
}
}
this._logger.debug(`Cleared ${entitiesToRemove.length} entities`);
});
// Get current camera position and orientation
// Camera may be parented to a platform, so we use world-space coordinates
document.addEventListener('chatGetCamera', () => {
this._logger.debug('chatGetCamera');
const camera = this._scene.activeCamera;
if (!camera) {
this._logger.warn('No active camera found');
return;
}
// World-space position (accounts for parent transforms)
const position = camera.globalPosition;
// World-space forward direction (where camera is looking)
const forward = camera.getForwardRay(1).direction;
// World up vector
const worldUp = new Vector3(0, 1, 0);
// Compute ground-projected forward (for intuitive forward/back movement)
// This ignores pitch so looking up/down doesn't affect horizontal movement
const groundForward = new Vector3(forward.x, 0, forward.z);
const groundForwardLength = groundForward.length();
if (groundForwardLength > 0.001) {
groundForward.scaleInPlace(1 / groundForwardLength);
} else {
// Looking straight up/down - use a fallback forward
groundForward.set(0, 0, -1);
}
// Compute right vector (perpendicular to groundForward in XZ plane)
// Right = Cross(groundForward, worldUp) gives left, so we negate or swap
const groundRight = Vector3.Cross(worldUp, groundForward).normalize();
const responseEvent = new CustomEvent('chatGetCameraResponse', {
detail: {
position: {x: position.x, y: position.y, z: position.z},
forward: {x: forward.x, y: forward.y, z: forward.z},
groundForward: {x: groundForward.x, y: groundForward.y, z: groundForward.z},
groundRight: {x: groundRight.x, y: groundRight.y, z: groundRight.z}
},
bubbles: true
});
document.dispatchEvent(responseEvent);
});
this._logger.debug("DiagramManager constructed");
}
public get actionManager(): AbstractActionManager {
return this._diagramEntityActionManager;
}
public get diagramMenuManager(): DiagramMenuManager {
return this._diagramMenuManager;
}
public getDiagramObject(id: string) {
return this._diagramObjects.get(id);
}
@ -116,11 +328,6 @@ export class DiagramManager {
return this._diagramObjects.has(mesh?.id)
}
public get controllers(): Controllers {
return this._controllers;
}
public createCopy(id: string): DiagramObject {
const diagramObject = this._diagramObjects.get(id);
if (!diagramObject) {
@ -135,14 +342,72 @@ export class DiagramManager {
this._diagramObjects.set(diagramObject.diagramEntity.id, diagramObject);
}
public get config(): AppConfig {
return this._config;
public get config() {
return appConfigInstance;
}
private findEntityByIdOrLabel(target: string): DiagramEntity | null {
// First try direct ID match
const byId = this._diagramObjects.get(target);
if (byId) {
return byId.diagramEntity;
}
// Then try label match (case-insensitive)
const targetLower = target.toLowerCase();
for (const [, obj] of this._diagramObjects) {
if (obj.diagramEntity.text?.toLowerCase() === targetLower) {
return obj.diagramEntity;
}
}
return null;
}
/**
* Generates a default label for an entity based on its color and shape.
* Format: "{color} {shape} {number}" e.g., "blue box 1", "red sphere 2"
* The number is determined by counting existing entities with the same prefix.
*/
private generateDefaultLabel(entity: DiagramEntity): string {
// Get color name from hex
const colorHex = entity.color?.toLowerCase() || '#0000ff';
const colorName = HEX_TO_COLOR_NAME[colorHex] || 'blue';
// Get shape name from template
const shapeName = TEMPLATE_TO_SHAPE[entity.template] || 'box';
// Create the prefix (e.g., "blue box")
const prefix = `${colorName} ${shapeName}`;
// Count existing entities with labels starting with this prefix
let maxNumber = 0;
for (const [, obj] of this._diagramObjects) {
const label = obj.diagramEntity.text?.toLowerCase() || '';
if (label.startsWith(prefix)) {
// Extract the number from the end of the label
const match = label.match(new RegExp(`^${prefix}\\s*(\\d+)$`));
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNumber) {
maxNumber = num;
}
}
}
}
// Return the next number in sequence
return `${prefix} ${maxNumber + 1}`;
}
private onDiagramEvent(event: DiagramEvent) {
let diagramObject = this._diagramObjects.get(event?.entity?.id);
switch (event.type) {
case DiagramEventType.CLEAR:
this._diagramObjects.forEach((value) => {
value.dispose();
});
this._diagramObjects.clear();
break;
case DiagramEventType.ADD:
if (diagramObject) {
diagramObject.fromDiagramEntity(event.entity);

View File

@ -1,49 +1,52 @@
import {DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import {AbstractMesh, ActionEvent, Observable, Scene, Vector3, WebXRInputSource} from "@babylonjs/core";
import {DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import {AbstractMesh, ActionEvent, Observable, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
import {InputTextView} from "../information/inputTextView";
import {DefaultScene} from "../defaultScene";
import {ControllerEvent, ControllerEventType, Controllers} from "../controllers/controllers";
import log from "loglevel";
import {Toolbox} from "../toolbox/toolbox";
import {ClickMenu} from "../menus/clickMenu";
import {ConfigMenu} from "../menus/configMenu";
import {AppConfig} from "../util/appConfig";
import {DiagramEventObserverMask} from "./types/diagramEventObserverMask";
import {ConnectionPreview} from "../menus/connectionPreview";
import {ScaleMenu2} from "../menus/ScaleMenu2";
import {viewOnly} from "../util/functions/getPath";
import {GroupMenu} from "../menus/groupMenu";
import {ControllerEvent} from "../controllers/types/controllerEvent";
import {ControllerEventType} from "../controllers/types/controllerEventType";
import {ResizeGizmo} from "../gizmos/ResizeGizmo";
import {VRConfigPanel} from "../menus/vrConfigPanel";
export class DiagramMenuManager {
public readonly toolbox: Toolbox;
public readonly scaleMenu: ScaleMenu2;
public readonly configMenu: ConfigMenu;
private readonly _notifier: Observable<DiagramEvent>;
private readonly _inputTextView: InputTextView;
private readonly _vrConfigPanel: VRConfigPanel;
private _groupMenu: GroupMenu;
private readonly _scene: Scene;
private _logger = log.getLogger('DiagramMenuManager');
private _connectionPreview: ConnectionPreview;
private _activeResizeGizmo: ResizeGizmo | null = null;
private _xr: WebXRDefaultExperience | null = null;
constructor(notifier: Observable<DiagramEvent>, controllers: Controllers, config: AppConfig, readyObservable: Observable<boolean>) {
constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) {
this._scene = DefaultScene.Scene;
this._notifier = notifier;
this._inputTextView = new InputTextView(controllers);
this.configMenu = new ConfigMenu(config);
this._inputTextView = new InputTextView(controllerObservable);
this._vrConfigPanel = new VRConfigPanel(this._scene);
//this.configMenu = new ConfigMenu(config);
this._inputTextView.onTextObservable.add((evt) => {
const event = {type: DiagramEventType.MODIFY, entity: {id: evt.id, text: evt.text}}
const event = {
type: DiagramEventType.MODIFY,
entity: {id: evt.id, text: evt.text, type: DiagramEntityType.ENTITY}
}
this._notifier.notifyObservers(event, DiagramEventObserverMask.FROM_DB);
});
this.toolbox = new Toolbox(readyObservable);
this.scaleMenu = new ScaleMenu2(this._notifier);
if (viewOnly()) {
this.toolbox.handleMesh.setEnabled(false);
//this.scaleMenu.handleMesh.setEnabled(false)
this.configMenu.handleTransformNode.setEnabled(false);
}
controllers.controllerObservable.add((event: ControllerEvent) => {
controllerObservable.add((event: ControllerEvent) => {
if (event.type == ControllerEventType.B_BUTTON) {
if (event.value > .8) {
const platform = this._scene.getMeshByName("platform");
@ -62,9 +65,10 @@ export class DiagramMenuManager {
if (inputY > (cameraPos.y - .2)) {
this._inputTextView.handleMesh.position.y = localCamera.y - .2;
}
const configY = this._inputTextView.handleMesh.absolutePosition.y;
const configY = this._vrConfigPanel.handleMesh.absolutePosition.y;
if (configY > (cameraPos.y - .2)) {
this.configMenu.handleTransformNode.position.y = localCamera.y - .2;
this._vrConfigPanel.handleMesh.position.y = localCamera.y - .2;
}
}
}
@ -86,6 +90,38 @@ export class DiagramMenuManager {
this._inputTextView.show(mesh);
}
public activateResizeGizmo(mesh: AbstractMesh) {
// Dispose existing gizmo if any
if (this._activeResizeGizmo) {
this._activeResizeGizmo.dispose();
this._activeResizeGizmo = null;
}
// XR must be available to create resize gizmo
if (!this._xr) {
this._logger.warn('Cannot activate resize gizmo: XR not initialized');
return;
}
// Create new resize gizmo for the mesh
this._activeResizeGizmo = new ResizeGizmo(mesh, this._xr);
// Listen for scale end event to notify diagram manager
this._activeResizeGizmo.onScaleEnd.add(() => {
this.notifyAll({
type: DiagramEventType.MODIFY,
entity: {id: mesh.id, type: DiagramEntityType.ENTITY}
});
});
}
public disposeResizeGizmo() {
if (this._activeResizeGizmo) {
this._activeResizeGizmo.dispose();
this._activeResizeGizmo = null;
}
}
public createClickMenu(mesh: AbstractMesh, input: WebXRInputSource): ClickMenu {
const clickMenu = new ClickMenu(mesh);
clickMenu.onClickMenuObservable.add((evt: ActionEvent) => {
@ -93,7 +129,10 @@ export class DiagramMenuManager {
switch (evt.source.id) {
case "remove":
this.notifyAll({type: DiagramEventType.REMOVE, entity: {id: clickMenu.mesh.id}});
this.notifyAll({
type: DiagramEventType.REMOVE,
entity: {id: clickMenu.mesh.id, type: DiagramEntityType.ENTITY}
});
break;
case "label":
this.editText(clickMenu.mesh);
@ -102,13 +141,13 @@ export class DiagramMenuManager {
this._connectionPreview = new ConnectionPreview(clickMenu.mesh.id, input, evt.additionalData.pickedPoint, this._notifier);
break;
case "size":
this.scaleMenu.show(clickMenu.mesh);
this.activateResizeGizmo(clickMenu.mesh);
break;
case "group":
this._groupMenu = new GroupMenu(clickMenu.mesh);
break;
case "close":
this.scaleMenu.hide();
this.disposeResizeGizmo();
break;
}
this._logger.debug(evt);
@ -121,4 +160,19 @@ export class DiagramMenuManager {
private notifyAll(event: DiagramEvent) {
this._notifier.notifyObservers(event, DiagramEventObserverMask.ALL);
}
public setXR(xr: WebXRDefaultExperience): void {
this._xr = xr;
this.toolbox.setXR(xr, this);
}
public toggleVRConfigPanel(): void {
// Toggle visibility of VR config panel
const isEnabled = this._vrConfigPanel.handleMesh.isEnabled(false);
if (isEnabled) {
this._vrConfigPanel.hide();
} else {
this._vrConfigPanel.show();
}
}
}

View File

@ -1,6 +1,7 @@
import {
AbstractActionManager,
AbstractMesh,
Color3,
Curve3,
GreasedLineMesh,
InstancedMesh,
@ -9,10 +10,11 @@ import {
Observer,
Ray,
Scene,
StandardMaterial,
TransformNode,
Vector3
} from "@babylonjs/core";
import {DiagramEntity, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import {buildMeshFromDiagramEntity} from "./functions/buildMeshFromDiagramEntity";
import {toDiagramEntity} from "./functions/toDiagramEntity";
import {v4 as uuidv4} from 'uuid';
@ -20,6 +22,22 @@ import {createLabel} from "./functions/createLabel";
import {DiagramEventObserverMask} from "./types/diagramEventObserverMask";
import log, {Logger} from "loglevel";
import {xyztovec} from "./functions/vectorConversion";
import {AnimatedLineTexture} from "../util/animatedLineTexture";
import {getToolboxColors} from "../toolbox/toolbox";
import {findClosestColor} from "../util/functions/findClosestColor";
import {appConfigInstance} from "../util/appConfig";
/**
* Converts a Color3 to a hex color string
* @param color - BabylonJS Color3
* @returns Hex color string (e.g., '#ff0000')
*/
function color3ToHex(color: Color3): string {
const r = Math.floor(color.r * 255).toString(16).padStart(2, '0');
const g = Math.floor(color.g * 255).toString(16).padStart(2, '0');
const b = Math.floor(color.b * 255).toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
}
type DiagramObjectOptionsType = {
diagramEntity?: DiagramEntity,
@ -29,6 +47,7 @@ type DiagramObjectOptionsType = {
export class DiagramObject {
private readonly _logger: Logger = log.getLogger('DiagramObject');
private _group: TransformNode;
private _scene: Scene;
public grabbed: boolean = false;
private _from: string;
@ -40,16 +59,25 @@ export class DiagramObject {
private _labelBack: InstancedMesh;
private _meshesPresent: boolean = false;
private _positionHash: string;
private _fromPosition: number = 0;
private _toPosition: number = 0;
private _disposed: boolean = false;
private _fromMesh: AbstractMesh;
private _toMesh: AbstractMesh;
private _meshRemovedObserver: Observer<AbstractMesh>;
private _configObserver: Observer<any>;
// Position caching for connection optimization
private _lastFromPosition: Vector3 = null;
private _lastToPosition: Vector3 = null;
private _positionTolerance: number = 0.001;
constructor(scene: Scene, eventObservable: Observable<DiagramEvent>, options?: DiagramObjectOptionsType) {
this._eventObservable = eventObservable;
this._scene = scene;
// Subscribe to config changes to update label rendering mode
this._configObserver = appConfigInstance.onConfigChangedObservable.add(() => {
this.updateLabelRenderingMode();
});
if (options) {
this._logger.debug('DiagramObject constructor called with options', options);
if (options.diagramEntity) {
@ -114,6 +142,84 @@ export class DiagramObject {
return this._diagramEntity;
}
public set position(value: { x: number; y: number; z: number }) {
if (this._baseTransform) {
this._baseTransform.position = new Vector3(value.x, value.y, value.z);
if (this._diagramEntity) {
this._diagramEntity.position = value;
}
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
}
}
public set scale(value: { x: number; y: number; z: number }) {
if (this._mesh) {
this._mesh.scaling = new Vector3(value.x, value.y, value.z);
if (this._diagramEntity) {
this._diagramEntity.scale = value;
}
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
// Update label position since entity size changed
this.updateLabelPosition();
}
}
public set rotation(value: { x: number; y: number; z: number }) {
if (this._baseTransform) {
this._baseTransform.rotation = new Vector3(value.x, value.y, value.z);
if (this._diagramEntity) {
this._diagramEntity.rotation = value;
}
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
}
}
public set color(value: string) {
if (!this._diagramEntity || this._diagramEntity.color === value) {
return;
}
this._logger.debug('Changing color from', this._diagramEntity.color, 'to', value);
// Update the entity color
this._diagramEntity.color = value;
// Rebuild mesh with new color (since instances share materials)
// Must dispose old mesh FIRST, otherwise buildMeshFromDiagramEntity
// finds it by ID and returns the same mesh (which we then dispose!)
if (this._mesh) {
const actionManager = this._mesh.actionManager;
this._mesh.dispose();
this._mesh = null;
this._mesh = buildMeshFromDiagramEntity(this._diagramEntity, this._scene);
if (this._mesh) {
this._mesh.setParent(this._baseTransform);
this._mesh.position = Vector3.Zero();
this._mesh.rotation = Vector3.Zero();
if (actionManager) {
this._mesh.actionManager = actionManager;
}
} else {
this._logger.error('Failed to rebuild mesh with new color');
}
}
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
}
public set text(value: string) {
if (this._label) {
this._label.dispose();
@ -121,50 +227,106 @@ export class DiagramObject {
if (this._labelBack) {
this._labelBack.dispose();
}
if (this._diagramEntity.text != value) {
const textChanged = this._diagramEntity.text != value;
// Update the entity text FIRST (before notifying observers)
this._diagramEntity.text = value;
// Also update mesh metadata to keep in sync with diagramEntity getter
if (this._mesh && this._mesh.metadata) {
this._mesh.metadata.text = value;
}
// THEN notify observers with the UPDATED entity
if (textChanged) {
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
}
this._diagramEntity.text = value;
this._label = createLabel(value);
this._label.parent = this._baseTransform;
this._labelBack = new InstancedMesh('labelBack' + value, (this._label as Mesh));
this._labelBack.parent = this._label;
this._labelBack.metadata = {exportable: true};
this.updateLabelPosition();
this.updateLabelRenderingMode();
}
private updateLabelRenderingMode() {
if (!this._label) {
return;
}
const mode = appConfigInstance.current.labelRenderingMode || 'billboard';
// Reset billboard mode first
this._label.billboardMode = Mesh.BILLBOARDMODE_NONE;
if (this._labelBack) {
this._labelBack.billboardMode = Mesh.BILLBOARDMODE_NONE;
}
switch (mode) {
case 'billboard':
// Billboard mode - labels always face camera (Y-axis only to prevent tilting)
this._label.billboardMode = Mesh.BILLBOARDMODE_Y;
if (this._labelBack) {
this._labelBack.billboardMode = Mesh.BILLBOARDMODE_Y;
}
break;
case 'fixed':
// Fixed mode - no billboard (default state, already set above)
break;
case 'dynamic':
// Dynamic mode - to be implemented in future
// TODO: Implement screen-space positioning
this._logger.warn('Dynamic label rendering mode not yet implemented');
break;
case 'distance':
// Distance-based mode - to be implemented in future
// TODO: Implement distance-based offset
this._logger.warn('Distance-based label rendering mode not yet implemented');
break;
}
// Update label position/rotation based on new mode (connections need different rotation in billboard vs fixed)
this.updateLabelPosition();
}
public updateLabelPosition() {
if (this._label) {
this._mesh.computeWorldMatrix(true);
this._mesh.refreshBoundingInfo({});
const isBillboard = (appConfigInstance.current.labelRenderingMode || 'billboard') === 'billboard';
if (this._from && this._to) {
//this._label.position.x = .06;
//this._label.position.z = .06;
// Connection labels (arrows/lines)
this._label.position.y = .05;
// Only set local rotation when NOT in billboard mode
// Billboard mode handles rotation automatically - setting local rotation causes conflicts
if (!isBillboard) {
this._label.rotation.y = Math.PI / 2;
this._labelBack.rotation.y = Math.PI;
this._labelBack.position.z = 0.001
//this._label.billboardMode = Mesh.BILLBOARDMODE_Y;
//this._label.billboardMode = Mesh.BILLBOARDMODE_Y;
} else {
const top =
this._mesh.getBoundingInfo().boundingBox.maximumWorld;
// Reset rotations for billboard mode
this._label.rotation.y = 0;
this._labelBack.rotation.y = Math.PI; // Back face still needs to be flipped
}
this._labelBack.position.z = 0.005;
} else {
// Standard object labels - convert world space to parent's local space
// This accounts for mesh scaling, which is not included in boundingBox.maximum
const top = this._mesh.getBoundingInfo().boundingBox.maximumWorld;
const temp = new TransformNode("temp", this._scene);
temp.position = top;
temp.setParent(this._baseTransform);
const y = temp.position.y;
temp.dispose();
this._label.position.y = y + .06;
//this._labelBack.position.y = y + .06;
this._label.position.y = y + 0.06;
this._labelBack.rotation.y = Math.PI;
this._labelBack.position.z = 0.001
//this._label.billboardMode = Mesh.BILLBOARDMODE_Y;
this._labelBack.position.z = 0.005;
}
}
}
@ -177,6 +339,7 @@ export class DiagramObject {
position: oldEntity.position,
rotation: oldEntity.rotation,
scale: oldEntity.scale,
type: DiagramEntityType.ENTITY,
image: oldEntity.image,
template: oldEntity.template,
color: oldEntity.color,
@ -208,28 +371,26 @@ export class DiagramObject {
if (!this._meshRemovedObserver) {
this._meshRemovedObserver = this._scene.onMeshRemovedObservable.add((mesh) => {
if (mesh && mesh.id) {
// When an endpoint mesh is removed, don't immediately dispose the connection.
// Instead, clear the mesh references and reset the timer. The scene observer
// will try to re-find the meshes (handles entity modification where mesh is
// disposed and recreated with same ID). If meshes can't be found after
// timeout, the scene observer will dispose the connection.
switch (mesh.id) {
case this._from:
this._fromMesh = null;
this._lastFromPosition = null;
this._meshesPresent = false;
this._eventObservable.notifyObservers({
type: DiagramEventType.REMOVE,
entity: this._diagramEntity
}, DiagramEventObserverMask.ALL);
this.dispose();
this._observingStart = Date.now(); // Reset timeout
break;
case this._to:
this._toMesh = null;
this._lastToPosition = null;
this._meshesPresent = false;
this._eventObservable.notifyObservers({
type: DiagramEventType.REMOVE,
entity: this._diagramEntity
}, DiagramEventObserverMask.ALL);
this.dispose();
this._observingStart = Date.now(); // Reset timeout
break;
}
}
}, -1, false, this);
}
if (!this._sceneObserver) {
@ -245,6 +406,9 @@ export class DiagramObject {
this._fromMesh = this._fromMesh || this._scene.getMeshById(this._from);
this._toMesh = this._toMesh || this._scene.getMeshById(this._to);
if (this._fromMesh && this._toMesh) {
// Reset cache to force initial update
this._lastFromPosition = null;
this._lastToPosition = null;
this.updateConnection();
this._meshesPresent = true;
} else {
@ -294,6 +458,8 @@ export class DiagramObject {
this._logger.debug('DiagramObject dispose called for ', this._diagramEntity?.id)
this._scene?.onAfterRenderObservable.remove(this._sceneObserver);
this._sceneObserver = null;
appConfigInstance?.onConfigChangedObservable.remove(this._configObserver);
this._configObserver = null;
this._mesh?.setParent(null);
this._mesh?.dispose(true, false);
this._mesh = null;
@ -304,12 +470,37 @@ export class DiagramObject {
this._scene = null;
this._fromMesh = null;
this._toMesh = null;
this._lastFromPosition = null;
this._lastToPosition = null;
this._scene?.onMeshRemovedObservable.remove(this._meshRemovedObserver);
this._disposed = true;
}
private hasConnectionMoved(): boolean {
if (!this._fromMesh || !this._toMesh) {
return false;
}
const currentFromPos = this._fromMesh.getAbsolutePosition();
const currentToPos = this._toMesh.getAbsolutePosition();
// First update - always consider it moved
if (this._lastFromPosition === null || this._lastToPosition === null) {
return true;
}
// Check if either endpoint has moved beyond tolerance
const fromMoved = Vector3.DistanceSquared(currentFromPos, this._lastFromPosition) >
(this._positionTolerance * this._positionTolerance);
const toMoved = Vector3.DistanceSquared(currentToPos, this._lastToPosition) >
(this._positionTolerance * this._positionTolerance);
return fromMoved || toMoved;
}
private updateConnection() {
if (this._toMesh.absolutePosition.length() == this._toPosition && this._fromMesh.absolutePosition.length() == this._fromPosition) {
// Early exit if positions haven't changed
if (!this.hasConnectionMoved()) {
return;
}
const curve: GreasedLineMesh = ((this._mesh as unknown) as GreasedLineMesh);
@ -321,6 +512,9 @@ export class DiagramObject {
return false;
}
});
if (!hit || hit.length < 2) {
return; // No valid intersection found, skip update
}
if (hit[0].pickedMesh.id === this._to) {
hit.reverse();
}
@ -337,10 +531,63 @@ export class DiagramObject {
curve.setParent(null);
curve.setPoints([p]);
this._baseTransform.position = c.getPoints()[Math.floor(c.getPoints().length / 2)];
this._toPosition = this._toMesh.absolutePosition.length();
this._fromPosition = this._fromMesh.absolutePosition.length();
// Update connection texture color to match the "from" mesh using toolbox color
let hexColor: string | null = null;
// Extract color using same priority system as toDiagramEntity
if (this._fromMesh.metadata?.color) {
// Priority 1: Explicit metadata color (most reliable)
hexColor = this._fromMesh.metadata.color;
} else if (this._fromMesh instanceof InstancedMesh && this._fromMesh.sourceMesh?.id) {
// Priority 2: Extract from tool mesh ID (e.g., "tool-#box-template-#FF0000")
const toolId = this._fromMesh.sourceMesh.id;
const parts = toolId.split('-');
if (parts.length >= 3 && parts[0] === 'tool') {
const color = parts.slice(2).join('-'); // Handle colors with dashes
if (color.startsWith('#')) {
hexColor = color.toLowerCase(); // Normalize to lowercase
}
}
} else {
// Priority 3: Fallback to material extraction
const fromMaterial = this._fromMesh.material as StandardMaterial;
if (fromMaterial) {
const fromColor = fromMaterial.diffuseColor || fromMaterial.emissiveColor || Color3.White();
hexColor = color3ToHex(fromColor);
}
}
if (hexColor) {
// Find the closest toolbox color
const availableColors = getToolboxColors();
const closestColor = findClosestColor(hexColor, availableColors);
// Get or create material
const material = curve.material as StandardMaterial;
if (material) {
// Check if we need to update the texture color
// Don't dispose cached textures - they're shared across connections!
const currentTextureName = material.emissiveTexture?.name || '';
const needsColorUpdate = !material.emissiveTexture ||
!currentTextureName.endsWith(closestColor);
if (needsColorUpdate) {
// Get cached texture for the new color (creates if needed)
const coloredTexture = AnimatedLineTexture.CreateColoredTexture(closestColor);
material.emissiveTexture = coloredTexture;
material.opacityTexture = coloredTexture;
}
// If color matches, keep existing texture reference (already correct)
}
}
// Update cached positions after successful update
this._lastFromPosition = this._fromMesh.getAbsolutePosition().clone();
this._lastToPosition = this._toMesh.getAbsolutePosition().clone();
curve.setParent(this._baseTransform);
curve.setEnabled(true);
console.log('done');
}
}

View File

@ -1,47 +0,0 @@
import {afterEach, describe, expect, it, vi} from 'vitest'
import {applyScaling} from './applyScaling'
import {Vector3} from "@babylonjs/core";
describe('applyScaling', () => {
afterEach(() => {
vi.restoreAllMocks();
})
it('should copy scaling', () => {
const oldMesh = {
scaling: {
clone: () => 'cloned'
}
}
const newMesh = {
scaling: null
}
applyScaling(oldMesh as any, newMesh as any, true, 0)
expect(newMesh.scaling).toBe('cloned')
})
it('scaling to be set to 1,1,1 if snap passed as null', () => {
const spy = vi.spyOn(Vector3, 'One');
//expect(spy).toHaveBeenCalledTimes(1);
const oldMesh = {
scaling: {}
}
const newMesh = {
scaling: null
}
applyScaling(oldMesh as any, newMesh as any, false, null)
expect(newMesh.scaling.x).toBe(1);
expect(newMesh.scaling.y).toBe(1);
expect(newMesh.scaling.z).toBe(1);
})
it('scaling to be set to 2,2,2 snap passed as Vector3(2,2,2)', () => {
const oldMesh = {
scaling: {}
}
const newMesh = {
scaling: new Vector3()
}
applyScaling(oldMesh as any, newMesh as any, false, 2)
expect(newMesh.scaling.x).toBe(2);
expect(newMesh.scaling.y).toBe(2);
expect(newMesh.scaling.z).toBe(2);
})
});

View File

@ -1,16 +0,0 @@
import {AbstractMesh, Vector3} from "@babylonjs/core";
export function applyScaling(oldMesh: AbstractMesh,
newMesh: AbstractMesh,
copy: boolean,
snap: number) {
if (copy) {
newMesh.scaling = oldMesh.scaling.clone();
} else {
if (snap) {
newMesh.scaling.set(snap, snap, snap);
} else {
newMesh.scaling = Vector3.One();
}
}
}

View File

@ -1,14 +1,16 @@
import {ActionManager, ExecuteCodeAction, HighlightLayer, InstancedMesh, StandardMaterial,} from "@babylonjs/core";
import {ControllerEventType, Controllers} from "../../controllers/controllers";
import {
ActionManager,
Color4,
ExecuteCodeAction,
InstancedMesh,
Observable,
} from "@babylonjs/core";
import log from "loglevel";
import {DefaultScene} from "../../defaultScene";
import {ControllerEventType} from "../../controllers/types/controllerEventType";
import {ControllerEvent} from "../../controllers/types/controllerEvent";
export function buildEntityActionManager(controllers: Controllers) {
const highlightLayer = new HighlightLayer('highlightLayer', DefaultScene.Scene);
highlightLayer.innerGlow = false;
highlightLayer.outerGlow = true;
export function buildEntityActionManager(controllerObservable: Observable<ControllerEvent>) {
const logger = log.getLogger('buildEntityActionManager');
const actionManager = new ActionManager(DefaultScene.Scene);
/*actionManager.registerAction(
@ -18,39 +20,31 @@ export function buildEntityActionManager(controllers: Controllers) {
if (evt.meshUnderPointer) {
try {
const mesh = evt.meshUnderPointer as InstancedMesh;
//mesh.sourceMesh.renderOutline = true;
if (mesh.sourceMesh) {
const newMesh = mesh.sourceMesh.clone(mesh.sourceMesh.name + '_clone', null, true);
newMesh.metadata = {};
newMesh.parent = null;
newMesh.position = mesh.absolutePosition;
newMesh.rotationQuaternion = mesh.absoluteRotationQuaternion;
newMesh.scaling = mesh.scaling;
newMesh.setEnabled(true);
newMesh.isPickable = false;
highlightLayer.addMesh(newMesh, (mesh.sourceMesh.material as StandardMaterial).diffuseColor.multiplyByFloats(1.5, 1.5, 1.5));
highlightLayer.setEffectIntensity(newMesh, 1.2);
mesh.metadata.highlight = newMesh;
console.log(newMesh);
// Enable edges rendering on the instance itself (not source mesh)
if (!mesh.edgesRenderer) {
mesh.enableEdgesRendering(0.99);
mesh.edgesWidth = .2;
mesh.edgesColor = new Color4(1, 1, 1, 1.0);
}
} catch (e) {
logger.error(e);
}
}
controllers.controllerObservable.notifyObservers({
controllerObservable.notifyObservers({
type: ControllerEventType.PULSE,
gripId: evt?.additionalData?.pickResult?.gripTransform?.id
})
});
logger.debug(evt);
})
);
actionManager.registerAction(
new ExecuteCodeAction(ActionManager.OnPointerOutTrigger, (evt) => {
try {
const mesh = evt.source;
if (mesh.metadata.highlight) {
mesh.metadata.highlight.dispose();
mesh.metadata.highlight = null;
const mesh = evt.source as InstancedMesh;
// Disable edges rendering on the instance itself
if (mesh?.edgesRenderer) {
mesh.disableEdgesRendering();
}
} catch (e) {
logger.error(e);

View File

@ -1,73 +0,0 @@
import {afterEach, describe, expect, it, vi} from 'vitest'
import {buildMeshFromDiagramEntity} from './buildMeshFromDiagramEntity'
import {DiagramEntityType} from "../types/diagramEntity";
import {Vector3} from "@babylonjs/core";
describe('buildMeshFromDiagramEntity', () => {
afterEach(() => {
vi.restoreAllMocks();
})
it('should return null if entity is null', () => {
const scene = {
getMeshById: () => null
}
const entity = buildMeshFromDiagramEntity(null, scene as any);
expect(entity).toBe(null);
});
it('should return existing mesh if id exists in scene', () => {
const material = 'material';
const scene = {
getMeshById: (id) => {
return {
id: id,
material: material
}
}
}
const dEntity = {
type: DiagramEntityType.USER,
}
const entity = buildMeshFromDiagramEntity(dEntity, scene as any);
expect(entity.material).toBe(material);
});
it('should generate new mesh if id is missing', () => {
vi.mock('../diagramConnection', () => {
const DiagramConnection = vi.fn();
DiagramConnection.prototype.mesh =
{
id: 'id',
material: 'material',
getChildren: vi.fn(),
getScene: vi.fn()
}
return {DiagramConnection}
});
const scene = {
getMeshById: () => {
return null;
},
}
const dEntity = {
type: DiagramEntityType.USER,
template: "#connection-template",
color: "$FF00FF",
position: {x: 1, y: 2, z: 3},
rotation: {x: 4, y: 5, z: 6},
scale: {x: 7, y: 8, z: 9},
text: 'new text'
}
const entity = buildMeshFromDiagramEntity(dEntity, scene as any);
expect(entity.id).toBe('id');
expect(entity.material).toBe('material');
expect(entity.position).toEqual(new Vector3(1, 2, 3));
expect(entity.rotation).toEqual(new Vector3(4, 5, 6));
expect(entity.scaling).toEqual(new Vector3(7, 8, 9));
expect(entity.metadata.text).toEqual('new text');
});
});

View File

@ -18,6 +18,16 @@ import log from "loglevel";
import {v4 as uuidv4} from 'uuid';
import {xyztovec} from "./vectorConversion";
import {AnimatedLineTexture} from "../../util/animatedLineTexture";
import {LightmapGenerator} from "../../util/lightmapGenerator";
import {getToolboxColors} from "../../toolbox/toolbox";
import {findClosestColor} from "../../util/functions/findClosestColor";
// Material sharing statistics
let materialStats = {
instancesCreated: 0,
materialsShared: 0,
materialsFallback: 0
};
export function buildMeshFromDiagramEntity(entity: DiagramEntity, scene: Scene): AbstractMesh {
const logger = log.getLogger('buildMeshFromDiagramEntity');
@ -72,6 +82,7 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst
material.emissiveTexture = AnimatedLineTexture.Texture();
material.opacityTexture = AnimatedLineTexture.Texture();
material.disableLighting = true;
material.metadata = { isConnection: true, preserveTextures: true }; // Preserve animated arrow textures
newMesh.setEnabled(false);
break;
case DiagramTemplates.BOX:
@ -80,12 +91,59 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst
case DiagramTemplates.CONE:
case DiagramTemplates.PLANE:
case DiagramTemplates.PERSON:
const toolMesh = scene.getMeshById("tool-" + entity.template + "-" + entity.color);
// Tool meshes are created with UPPERCASE hex codes (BabylonJS toHexString behavior)
let toolMeshId = "tool-" + entity.template + "-" + entity.color?.toUpperCase();
let toolMesh = scene.getMeshById(toolMeshId);
// If exact color match not found, try to find closest color
if (!toolMesh && entity.color) {
const availableColors = getToolboxColors();
const closestColor = findClosestColor(entity.color, availableColors);
if (closestColor !== entity.color.toLowerCase()) {
logger.info(`Color ${entity.color} not found in toolbox, using closest match: ${closestColor}`);
// Tool IDs use uppercase hex codes
toolMeshId = "tool-" + entity.template + "-" + closestColor.toUpperCase();
toolMesh = scene.getMeshById(toolMeshId);
if (toolMesh) {
logger.info(`Successfully found tool mesh with closest color: ${toolMeshId}`);
} else {
logger.error(`Even with closest color, tool mesh not found: ${toolMeshId}`);
}
}
}
if (toolMesh && !oldMesh) {
// Verify tool mesh has material before creating instance
if (!toolMesh.material) {
logger.error(`Tool mesh ${toolMeshId} found but has no material! This should never happen.`);
logger.error(`Tool mesh state: enabled=${toolMesh.isEnabled()}, parent=${toolMesh.parent?.name}`);
// Don't create instance without material
break;
}
logger.debug(`Found tool mesh: ${toolMeshId}, material: ${toolMesh.material.id}`);
newMesh = new InstancedMesh(entity.id, (toolMesh as Mesh));
// InstancedMesh.material property delegates to sourceMesh.material automatically
logger.debug(`Created instance ${entity.id}, inherited material: ${newMesh.material?.id}`);
// Track material sharing statistics
materialStats.instancesCreated++;
if (newMesh.material) {
materialStats.materialsShared++;
}
// newMesh.metadata = {template: entity.template, exportable: true, tool: false};
} else {
logger.warn('no tool mesh found for ' + entity.template + "-" + entity.color);
if (!toolMesh) {
logger.warn(`No tool mesh found for ${toolMeshId}. Available tool meshes: ${
scene.meshes.filter(m => m.id.startsWith('tool-')).map(m => m.id).slice(0, 5).join(', ')
}...`);
}
if (oldMesh) {
logger.debug(`Skipping instance creation, mesh ${entity.id} already exists`);
}
}
break;
default:
@ -101,6 +159,11 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst
newMesh.metadata.tool = false;
}
// Store color in metadata so it persists when entity is modified
if (entity.color) {
newMesh.metadata.color = entity.color;
}
}
}
return newMesh;
@ -112,6 +175,7 @@ function buildImage(entity: DiagramEntity, scene: Scene): AbstractMesh {
logger.debug("buildImage: entity is image");
const plane = MeshBuilder.CreatePlane(entity.id, {size: 1}, scene);
const material = new StandardMaterial("planeMaterial", scene);
material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications
const image = new Image();
image.src = entity.image;
material.emissiveTexture = new Texture(entity.image, scene);
@ -161,9 +225,31 @@ function mapMetadata(entity: DiagramEntity, newMesh: AbstractMesh, scene: Scene)
/*if (entity.scale) {
newMesh.scaling = xyztovec(entity.scale);
}*/
// Material validation - InstancedMesh should automatically inherit material from source mesh
if (!newMesh.material && newMesh?.metadata?.template != "#object-template") {
logger.warn("new material created, this shouldn't happen");
logger.error(`MATERIAL SHARING FAILURE for mesh ${newMesh.id}:`);
logger.error(` Template: ${newMesh.metadata?.template}`);
logger.error(` Color: ${entity.color}`);
logger.error(` Is InstancedMesh: ${newMesh instanceof InstancedMesh}`);
if (newMesh instanceof InstancedMesh) {
logger.error(` Source mesh: ${newMesh.sourceMesh?.id}`);
logger.error(` Source mesh material: ${newMesh.sourceMesh?.material?.id || 'MISSING'}`);
logger.error(` This indicates tool mesh was created without material!`);
}
// Create fallback material as last resort to prevent crashes
logger.warn(`Creating fallback material to prevent crash - this impacts performance!`);
newMesh.material = buildMissingMaterial("material-" + entity.id, scene, entity.color);
// Track fallback material creation
materialStats.materialsFallback++;
}
// Log material sharing statistics periodically
if (materialStats.instancesCreated > 0 && materialStats.instancesCreated % 10 === 0) {
const sharingRate = (materialStats.materialsShared / materialStats.instancesCreated * 100).toFixed(1);
logger.info(`Material Sharing Stats: ${materialStats.materialsShared}/${materialStats.instancesCreated} (${sharingRate}%), Fallbacks: ${materialStats.materialsFallback}`);
}
if (entity.text) {
newMesh.metadata.text = entity.text;
@ -190,9 +276,21 @@ export function buildMissingMaterial(name: string, scene: Scene, color: string):
if (existingMaterial) {
return (existingMaterial as StandardMaterial);
}
const colorObj = Color3.FromHexString(color);
const newMaterial = new StandardMaterial(name, scene);
newMaterial.id = name;
newMaterial.diffuseColor = Color3.FromHexString(color);
if (LightmapGenerator.ENABLED) {
// Lightmap as emissive texture (lighting illusion, no lighting calculations)
newMaterial.emissiveColor = colorObj;
newMaterial.emissiveTexture = LightmapGenerator.generateLightmapForColor(colorObj, scene);
newMaterial.disableLighting = true;
} else {
// Flat emissive-only rendering (no lighting illusion)
newMaterial.emissiveColor = colorObj;
newMaterial.disableLighting = true;
}
newMaterial.alpha = 1;
return newMaterial;
}

View File

@ -32,11 +32,12 @@ function createDynamicTexture(text: string, font: string, DTWidth: number, DTHei
function createMaterial(dynamicTexture: DynamicTexture): Material {
const mat = new StandardMaterial("text-mat", DefaultScene.Scene);
//mat.diffuseColor = Color3.Black();
mat.disableLighting = false;
//mat.backFaceCulling = false;
mat.disableLighting = true;
mat.backFaceCulling = true;
mat.emissiveTexture = dynamicTexture;
mat.diffuseTexture = dynamicTexture;
mat.metadata = {exportable: true};
mat.metadata = { exportable: true, isUI: true }; // Mark as UI to prevent rendering mode modifications
//mat.freeze();
return mat;
}

View File

@ -0,0 +1,85 @@
import {AbstractMesh, Vector3} from "@babylonjs/core";
import {DiagramManager} from "../diagramManager";
import {DiagramObject} from "../diagramObject";
import {MeshTypeEnum} from "../types/meshTypeEnum";
import {snapAll} from "../../controllers/functions/snapAll";
import {DiagramEvent, DiagramEventType} from "../types/diagramEntity";
import {DiagramEventObserverMask} from "../types/diagramEventObserverMask";
import {DefaultScene} from "../../defaultScene";
import {appConfigInstance} from "../../util/appConfig";
import {HandleConfig, Vec3} from "../../util/appConfigType";
export function dropMesh(mesh: AbstractMesh,
grabbedObject: DiagramObject,
pickPoint: Vector3,
grabbedMeshType: MeshTypeEnum,
diagramManager: DiagramManager): boolean {
if (!mesh) {
return false;
}
let dropped = false;
const diagramObject = grabbedObject;
switch (grabbedMeshType) {
case MeshTypeEnum.ENTITY:
if (diagramObject) {
diagramObject.baseTransform.setParent(null);
snapAll(grabbedObject.baseTransform, pickPoint);
diagramObject.mesh.computeWorldMatrix(true);
const event: DiagramEvent =
{
type: DiagramEventType.DROP,
entity: diagramObject.diagramEntity
}
diagramManager.onDiagramEventObservable.notifyObservers(event, DiagramEventObserverMask.ALL);
diagramObject.mesh.computeWorldMatrix(false);
diagramObject.grabbed = false;
dropped = true;
}
break;
case MeshTypeEnum.TOOL:
grabbedObject.baseTransform.setParent(null);
snapAll(grabbedObject.baseTransform, pickPoint);
diagramObject.mesh.computeWorldMatrix(true);
const event: DiagramEvent =
{
type: DiagramEventType.DROP,
entity: diagramObject.diagramEntity
}
diagramManager.onDiagramEventObservable.notifyObservers(event, DiagramEventObserverMask.ALL);
diagramObject.mesh.computeWorldMatrix(false);
grabbedObject.grabbed = false;
dropped = true;
break;
case MeshTypeEnum.HANDLE:
mesh.setParent(DefaultScene.Scene.getMeshByName("platform"));
// Get existing handle config or create new one
const existingConfig = appConfigInstance.getHandleConfig(mesh.id);
// Convert Vector3 to Vec3 for serialization
const position: Vec3 = {
x: mesh.position.x,
y: mesh.position.y,
z: mesh.position.z
};
const rotation: Vec3 = {
x: mesh.rotation.x,
y: mesh.rotation.y,
z: mesh.rotation.z
};
const handleConfig: HandleConfig = {
id: mesh.id,
label: existingConfig?.label || mesh.id, // Preserve label if exists
position: position,
rotation: rotation,
scale: existingConfig?.scale // Preserve scale if exists
};
// Save to AppConfig (which persists to localStorage)
appConfigInstance.setHandleConfig(handleConfig);
dropped = true;
break;
}
return dropped;
}

View File

@ -0,0 +1,40 @@
import {viewOnly} from "../../util/functions/getPath";
import {getMeshType} from "../../controllers/functions/getMeshType";
import {MeshTypeEnum} from "../types/meshTypeEnum";
import {grabAndClone} from "../../controllers/functions/grabAndClone";
import {AbstractMesh} from "@babylonjs/core";
import log from "loglevel";
import {DiagramManager} from "../diagramManager";
import {DiagramObject} from "../diagramObject";
export function grabMesh(mesh: AbstractMesh, diagramManager: DiagramManager, controllerMesh: AbstractMesh):
{ grabbedMesh: AbstractMesh | null, grabbedObject: DiagramObject | null, grabbedMeshType: MeshTypeEnum | null } {
const logger = log.getLogger('grabMesh');
if (!mesh || viewOnly()) {
return {grabbedMesh: null, grabbedObject: null, grabbedMeshType: null};
}
let grabbedMesh = mesh;
let grabbedObject: DiagramObject | null = null;
let grabbedMeshType = getMeshType(mesh, diagramManager);
//displayDebug(mesh);
logger.debug("grabbing " + mesh.id + " type " + grabbedMeshType);
switch (grabbedMeshType) {
case MeshTypeEnum.ENTITY:
const diagramObject = diagramManager.getDiagramObject(mesh.id);
if (diagramObject.isGrabbable) {
diagramObject.baseTransform.setParent(controllerMesh);
diagramObject.grabbed = true;
grabbedObject = diagramObject;
}
break;
case MeshTypeEnum.HANDLE:
grabbedMesh.setParent(controllerMesh);
break;
case MeshTypeEnum.TOOL:
const clone = grabAndClone(diagramManager, mesh, controllerMesh);
grabbedObject = clone;
grabbedMesh = clone.mesh;
clone.grabbed = true;
}
return {grabbedMesh, grabbedObject, grabbedMeshType};
}

View File

@ -1,4 +1,4 @@
import {AbstractMesh} from "@babylonjs/core";
import {AbstractMesh, InstancedMesh} from "@babylonjs/core";
import {DiagramEntity} from "../types/diagramEntity";
import log from "loglevel";
import {v4 as uuidv4} from 'uuid';
@ -25,20 +25,44 @@ export function toDiagramEntity(mesh: AbstractMesh): DiagramEntity {
entity.from = mesh?.metadata?.from;
entity.to = mesh?.metadata?.to;
entity.scale = vectoxys(mesh.scaling);
if (mesh.material) {
// Extract color using fallback chain for reliability
if (mesh.metadata?.color) {
// Priority 1: Explicit metadata color (most reliable)
entity.color = mesh.metadata.color;
} else if (mesh instanceof InstancedMesh && mesh.sourceMesh?.id) {
// Priority 2: Extract from tool mesh ID (e.g., "tool-BOX-#FF0000")
const toolId = mesh.sourceMesh.id;
const parts = toolId.split('-');
if (parts.length >= 3 && parts[0] === 'tool') {
const color = parts.slice(2).join('-'); // Handle colors with dashes
if (color.startsWith('#')) {
entity.color = color.toLowerCase(); // Normalize to lowercase
}
}
} else if (mesh.material) {
// Priority 3: Fallback to material extraction (backwards compatibility)
switch (mesh.material.getClassName()) {
case "StandardMaterial":
entity.color = (mesh.material as any).diffuseColor.toHexString();
const stdMat = mesh.material as any;
const stdColor = stdMat.emissiveColor || stdMat.diffuseColor;
if (stdColor) {
entity.color = stdColor.toHexString()?.toLowerCase();
}
break;
case "PBRMaterial":
entity.color = (mesh.material as any).albedoColor.toHexString();
const pbrMat = mesh.material as any;
const pbrColor = pbrMat.emissiveColor || pbrMat.albedoColor;
if (pbrColor) {
entity.color = pbrColor.toHexString()?.toLowerCase();
}
break;
}
} else {
if (entity.template != "#object-template") {
logger.error("toDiagramEntity: mesh.material is null");
}
}
return entity;
}

View File

@ -1,54 +0,0 @@
import {PresentationStep} from "./presentationStep";
import log, {Logger} from "loglevel";
import {Scene} from "@babylonjs/core";
import {isDiagramEntity} from "./functions/isDiagramEntity";
export class PresentationManager {
_currentStep: PresentationStep = null;
private scene: Scene;
private logger: Logger = log.getLogger("PresentationManager");
constructor(scene: Scene) {
this.scene = scene;
}
_steps: PresentationStep[] = [];
public get steps(): PresentationStep[] {
return this._steps;
}
public addStep(): PresentationStep {
const step = new PresentationStep();
this._currentStep = step;
if (this._steps.length > 0) {
this._steps[this._steps.length - 1].next = step;
} else {
this.scene.getActiveMeshes().forEach((mesh) => {
if (isDiagramEntity(mesh)) {
step.entities.push({
entity: mesh,
endPosition: mesh.position.clone(),
endRotation: mesh.rotation.clone(),
endScaling: mesh.scaling.clone()
})
step.duration = 1;
}
});
}
this._steps.push(step);
return step;
}
public play() {
this._currentStep.play();
if (this._currentStep.next) {
this._currentStep = this._currentStep.next;
}
}
public reset() {
this._currentStep = this._steps[0];
this._steps[0].play();
}
}

Some files were not shown because too many files have changed in this diff Show More