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>
This commit is contained in:
Michael Mainguy 2025-12-20 16:14:44 -06:00
parent c9dc61b918
commit a7aa385d98
4 changed files with 213 additions and 15 deletions

View File

@ -1,4 +1,4 @@
import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, WebXRDefaultExperience} from "@babylonjs/core"; import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, Vector3, WebXRDefaultExperience} from "@babylonjs/core";
import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity"; import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import log from "loglevel"; import log from "loglevel";
@ -220,6 +220,7 @@ export class DiagramManager {
}); });
// Get current camera position and orientation // Get current camera position and orientation
// Camera may be parented to a platform, so we use world-space coordinates
document.addEventListener('chatGetCamera', () => { document.addEventListener('chatGetCamera', () => {
this._logger.debug('chatGetCamera'); this._logger.debug('chatGetCamera');
const camera = this._scene.activeCamera; const camera = this._scene.activeCamera;
@ -228,15 +229,36 @@ export class DiagramManager {
return; return;
} }
// World-space position (accounts for parent transforms)
const position = camera.globalPosition; const position = camera.globalPosition;
// World-space forward direction (where camera is looking)
const forward = camera.getForwardRay(1).direction; const forward = camera.getForwardRay(1).direction;
const up = camera.upVector || {x: 0, y: 1, z: 0};
// 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', { const responseEvent = new CustomEvent('chatGetCameraResponse', {
detail: { detail: {
position: {x: position.x, y: position.y, z: position.z}, position: {x: position.x, y: position.y, z: position.z},
forward: {x: forward.x, y: forward.y, z: forward.z}, forward: {x: forward.x, y: forward.y, z: forward.z},
up: {x: up.x, y: up.y, z: up.z} groundForward: {x: groundForward.x, y: groundForward.y, z: groundForward.z},
groundRight: {x: groundRight.x, y: groundRight.y, z: groundRight.z}
}, },
bubbles: true bubbles: true
}); });

View File

@ -9,6 +9,61 @@ logger.setLevel('debug');
// Session management // Session management
let currentSessionId: string | null = null; let currentSessionId: string | null = null;
// Model management
export interface ModelInfo {
id: string;
name: string;
description: string;
}
const AVAILABLE_MODELS: ModelInfo[] = [
{
id: 'claude-sonnet-4-20250514',
name: 'Claude Sonnet 4',
description: 'Balanced performance and speed (default)'
},
{
id: 'claude-opus-4-20250514',
name: 'Claude Opus 4',
description: 'Most capable, best for complex tasks'
},
{
id: 'claude-haiku-3-5-20241022',
name: 'Claude Haiku 3.5',
description: 'Fastest responses, good for simple tasks'
}
];
let currentModelId: string = 'claude-sonnet-4-20250514';
/**
* Get available models
*/
export function getAvailableModels(): ModelInfo[] {
return [...AVAILABLE_MODELS];
}
/**
* Get current model
*/
export function getCurrentModel(): ModelInfo {
return AVAILABLE_MODELS.find(m => m.id === currentModelId) || AVAILABLE_MODELS[0];
}
/**
* Set current model
*/
export function setCurrentModel(modelId: string): boolean {
const model = AVAILABLE_MODELS.find(m => m.id === modelId);
if (model) {
currentModelId = modelId;
logger.info('Model changed to:', model.name);
return true;
}
logger.warn('Invalid model ID:', modelId);
return false;
}
/** /**
* Create a new session or resume existing one for a diagram * Create a new session or resume existing one for a diagram
*/ */
@ -90,15 +145,24 @@ const SYSTEM_PROMPT = `You are a 3D diagram assistant helping users create and m
Available entity shapes: box, sphere, cylinder, cone, plane, person Available entity shapes: box, sphere, cylinder, cone, plane, person
Available colors: red, blue, green, yellow, orange, purple, cyan, pink, white, black, brown, gray (or any hex color like #ff5500) Available colors: red, blue, green, yellow, orange, purple, cyan, pink, white, black, brown, gray (or any hex color like #ff5500)
Position coordinates: IMPORTANT - Handling directional commands (left, right, forward, back, in front of me, etc.):
- x: left/right (negative = left, positive = right) When the user uses relative directions, you MUST first call get_camera_position to get their current view.
The response provides:
- Their world position
- Ground-projected forward/right vectors (accounts for camera being on a moving platform)
- Pre-calculated positions for forward/back/left/right at 2m distance
Use the ground-plane directions for intuitive placement - these ignore vertical look angle so "forward" means "forward on the ground" not "where I'm looking vertically".
Position coordinates (when not using relative directions):
- x: world left/right
- y: up/down (1.5 is eye level, 0 is floor) - y: up/down (1.5 is eye level, 0 is floor)
- z: forward/backward (positive = toward user, negative = away) - z: world forward/backward
When creating diagrams, think about good spatial layout: When creating diagrams, think about good spatial layout:
- Spread entities apart to avoid overlap (at least 0.5 units) - Spread entities apart to avoid overlap (at least 0.5 units)
- Use y=1.5 for entities at eye level - Use y=1.5 for entities at eye level
- Use z=2 to z=4 for comfortable viewing distance - For relative placement, use get_camera_position first
Always use the provided tools to create, modify, or interact with entities. Be concise in your responses.`; Always use the provided tools to create, modify, or interact with entities. Be concise in your responses.`;
@ -230,6 +294,36 @@ const TOOLS = [
type: "object", type: "object",
properties: {} properties: {}
} }
},
{
name: "list_models",
description: "List all available AI models that can be used for this conversation.",
input_schema: {
type: "object",
properties: {}
}
},
{
name: "get_current_model",
description: "Get information about the currently active AI model.",
input_schema: {
type: "object",
properties: {}
}
},
{
name: "set_model",
description: "Change the AI model used for this conversation. Use list_models first to see available options.",
input_schema: {
type: "object",
properties: {
model_id: {
type: "string",
description: "The model ID to switch to (e.g., 'claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-haiku-3-5-20241022')"
}
},
required: ["model_id"]
}
} }
]; ];
@ -285,6 +379,47 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
case 'get_camera_position': case 'get_camera_position':
result = await getCameraPosition(); result = await getCameraPosition();
break; break;
case 'list_models': {
const models = getAvailableModels();
const current = getCurrentModel();
const modelList = models.map(m =>
`${m.name} (${m.id})${m.id === current.id ? ' [CURRENT]' : ''}\n ${m.description}`
).join('\n\n');
result = {
toolName: 'list_models',
success: true,
message: `Available models:\n\n${modelList}`
};
break;
}
case 'get_current_model': {
const model = getCurrentModel();
result = {
toolName: 'get_current_model',
success: true,
message: `Current model: ${model.name} (${model.id})\n${model.description}`
};
break;
}
case 'set_model': {
const success = setCurrentModel(toolCall.input.model_id);
if (success) {
const model = getCurrentModel();
result = {
toolName: 'set_model',
success: true,
message: `Model changed to: ${model.name}\n${model.description}\n\nNote: The new model will be used starting from the next message.`
};
} else {
const models = getAvailableModels();
result = {
toolName: 'set_model',
success: false,
message: `Invalid model ID: "${toolCall.input.model_id}"\n\nAvailable models: ${models.map(m => m.id).join(', ')}`
};
}
break;
}
default: default:
result = { result = {
toolName: 'unknown', toolName: 'unknown',
@ -332,7 +467,7 @@ export async function sendMessage(
logger.debug(`[sendMessage] Loop iteration ${loopCount}`); logger.debug(`[sendMessage] Loop iteration ${loopCount}`);
const requestBody = { const requestBody = {
model: 'claude-sonnet-4-20250514', model: currentModelId,
max_tokens: 1024, max_tokens: 1024,
system: SYSTEM_PROMPT, system: SYSTEM_PROMPT,
tools: TOOLS, tools: TOOLS,

View File

@ -313,21 +313,55 @@ export async function clearDiagram(params: ClearDiagramParams): Promise<ToolResu
/** /**
* Get the current camera position and orientation * Get the current camera position and orientation
* Returns world-space coordinates with ground-projected directions for intuitive placement
*/ */
export function getCameraPosition(): Promise<ToolResult> { export function getCameraPosition(): Promise<ToolResult> {
logger.debug('[getCameraPosition] Getting camera position...'); logger.debug('[getCameraPosition] Getting camera position...');
return new Promise((resolve) => { return new Promise((resolve) => {
const responseHandler = (e: CustomEvent) => { const responseHandler = (e: CustomEvent) => {
document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener); document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener);
const {position, forward, up} = e.detail; const {position, forward, groundForward, groundRight} = e.detail;
logger.debug('[getCameraPosition] Got response:', e.detail); logger.debug('[getCameraPosition] Got response:', e.detail);
const message = `Camera position: (${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}) // Compute example positions for each direction
Forward direction: (${forward.x.toFixed(2)}, ${forward.y.toFixed(2)}, ${forward.z.toFixed(2)}) const distance = 2;
Up direction: (${up.x.toFixed(2)}, ${up.y.toFixed(2)}, ${up.z.toFixed(2)}) const frontPos = {
x: position.x + groundForward.x * distance,
y: position.y,
z: position.z + groundForward.z * distance
};
const rightPos = {
x: position.x + groundRight.x * distance,
y: position.y,
z: position.z + groundRight.z * distance
};
const leftPos = {
x: position.x - groundRight.x * distance,
y: position.y,
z: position.z - groundRight.z * distance
};
const backPos = {
x: position.x - groundForward.x * distance,
y: position.y,
z: position.z - groundForward.z * distance
};
To place an entity in front of the camera, add the forward direction scaled by desired distance to the camera position. const message = `User's current view (world coordinates):
Example: For an entity 2 units in front: position = (${(position.x + forward.x * 2).toFixed(2)}, ${(position.y + forward.y * 2).toFixed(2)}, ${(position.z + forward.z * 2).toFixed(2)})`;
Position: (${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)})
Looking direction: (${forward.x.toFixed(2)}, ${forward.y.toFixed(2)}, ${forward.z.toFixed(2)})
Ground-plane directions (use these for left/right/forward/back):
Forward: (${groundForward.x.toFixed(2)}, 0, ${groundForward.z.toFixed(2)})
Right: (${groundRight.x.toFixed(2)}, 0, ${groundRight.z.toFixed(2)})
To place entities relative to user (${distance}m away, at eye level y=${position.y.toFixed(1)}):
FORWARD: (${frontPos.x.toFixed(2)}, ${frontPos.y.toFixed(2)}, ${frontPos.z.toFixed(2)})
BACK: (${backPos.x.toFixed(2)}, ${backPos.y.toFixed(2)}, ${backPos.z.toFixed(2)})
RIGHT: (${rightPos.x.toFixed(2)}, ${rightPos.y.toFixed(2)}, ${rightPos.z.toFixed(2)})
LEFT: (${leftPos.x.toFixed(2)}, ${leftPos.y.toFixed(2)}, ${leftPos.z.toFixed(2)})
Formula: position + (groundForward * distance) for forward, position + (groundRight * distance) for right, etc.`;
resolve({ resolve({
toolName: 'get_camera_position', toolName: 'get_camera_position',

View File

@ -46,6 +46,10 @@ export interface ClearDiagramParams {
confirmed: boolean; confirmed: boolean;
} }
export interface SetModelParams {
model_id: string;
}
export type DiagramToolCall = export type DiagramToolCall =
| { name: 'create_entity'; input: CreateEntityParams } | { name: 'create_entity'; input: CreateEntityParams }
| { name: 'connect_entities'; input: ConnectEntitiesParams } | { name: 'connect_entities'; input: ConnectEntitiesParams }
@ -53,7 +57,10 @@ export type DiagramToolCall =
| { name: 'modify_entity'; input: ModifyEntityParams } | { name: 'modify_entity'; input: ModifyEntityParams }
| { name: 'list_entities'; input: Record<string, never> } | { name: 'list_entities'; input: Record<string, never> }
| { name: 'clear_diagram'; input: ClearDiagramParams } | { name: 'clear_diagram'; input: ClearDiagramParams }
| { name: 'get_camera_position'; input: Record<string, never> }; | { name: 'get_camera_position'; input: Record<string, never> }
| { name: 'list_models'; input: Record<string, never> }
| { name: 'get_current_model'; input: Record<string, never> }
| { name: 'set_model'; input: SetModelParams };
export const SHAPE_TO_TEMPLATE: Record<CreateEntityParams['shape'], DiagramTemplates> = { export const SHAPE_TO_TEMPLATE: Record<CreateEntityParams['shape'], DiagramTemplates> = {
box: DiagramTemplates.BOX, box: DiagramTemplates.BOX,