From 61f9519d44200a19b0f12aa68ac3efa45545b00c Mon Sep 17 00:00:00 2001 From: Code Sky Date: Sat, 18 Jul 2026 12:04:08 +0000 Subject: [PATCH] refactor: extract shared apiClient and vectorMath utilities Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/features/ai/aiConfigApi.ts | 43 +++---------------- src/features/auth/AuthProvider.tsx | 7 +-- src/features/auth/authApi.ts | 33 ++------------ src/features/cloud/cloudApi.ts | 37 +++------------- .../experiments/mechanics/centripetalForce.ts | 28 +++--------- .../mechanics/curvilinearMotion.ts | 14 +++--- .../mechanics/energyConservation.ts | 10 ++--- .../experiments/mechanics/freeFall.ts | 4 +- .../experiments/mechanics/galileoIncline.ts | 4 +- .../mechanics/motionComposition.ts | 4 +- .../experiments/mechanics/newtonSecondLaw.ts | 4 +- .../experiments/mechanics/tickerTimer.ts | 7 ++- src/shared/utils/apiClient.ts | 35 +++++++++++++++ src/shared/utils/vectorMath.ts | 22 ++++++++++ 14 files changed, 100 insertions(+), 152 deletions(-) create mode 100644 src/shared/utils/apiClient.ts create mode 100644 src/shared/utils/vectorMath.ts diff --git a/src/features/ai/aiConfigApi.ts b/src/features/ai/aiConfigApi.ts index 925309c..8f28916 100644 --- a/src/features/ai/aiConfigApi.ts +++ b/src/features/ai/aiConfigApi.ts @@ -1,33 +1,8 @@ import type { AiConfig } from './aiConfigTypes' - -const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '/api' - -async function request(path: string, options: RequestInit = {}): Promise { - const token = localStorage.getItem('phyverse-token') - const headers: Record = { - 'Content-Type': 'application/json', - ...((options.headers as Record) ?? {}), - } - if (token) { - headers.Authorization = `Bearer ${token}` - } - - const response = await fetch(`${API_BASE}${path}`, { - ...options, - headers, - }) - - const data = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(data.error ?? `Request failed with status ${response.status}`) - } - - return data as T -} +import { API_BASE, apiRequest, buildAuthHeaders } from '@/shared/utils/apiClient' export async function fetchAiConfig(): Promise<{ config: AiConfig | null }> { - return request<{ config: AiConfig | null }>('/ai-config') + return apiRequest<{ config: AiConfig | null }>('/ai-config') } export async function saveAiConfig(payload: { @@ -36,14 +11,14 @@ export async function saveAiConfig(payload: { model: string apiKey: string }): Promise<{ config: AiConfig }> { - return request<{ config: AiConfig }>('/ai-config', { + return apiRequest<{ config: AiConfig }>('/ai-config', { method: 'POST', body: JSON.stringify(payload), }) } export async function deleteAiConfig(): Promise { - await request('/ai-config', { + await apiRequest('/ai-config', { method: 'DELETE', }) } @@ -55,17 +30,9 @@ export async function sendAiChat(payload: { temperature?: number max_tokens?: number }): Promise { - const token = localStorage.getItem('phyverse-token') - const headers: Record = { - 'Content-Type': 'application/json', - } - if (token) { - headers.Authorization = `Bearer ${token}` - } - return fetch(`${API_BASE}/ai/chat`, { method: 'POST', - headers, + headers: buildAuthHeaders(), body: JSON.stringify(payload), }) } diff --git a/src/features/auth/AuthProvider.tsx b/src/features/auth/AuthProvider.tsx index c3ee3c1..c85bca8 100644 --- a/src/features/auth/AuthProvider.tsx +++ b/src/features/auth/AuthProvider.tsx @@ -2,15 +2,10 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react import { AuthContext } from './AuthContext' import type { User } from './authTypes' import { login as loginApi, register as registerApi, fetchCurrentUser } from './authApi' +import { TOKEN_KEY, getStoredToken } from '@/shared/utils/apiClient' -const TOKEN_KEY = 'phyverse-token' const USER_KEY = 'phyverse-user' -function getStoredToken(): string | null { - if (typeof window === 'undefined') return null - return window.localStorage.getItem(TOKEN_KEY) -} - function getStoredUser(): User | null { if (typeof window === 'undefined') return null try { diff --git a/src/features/auth/authApi.ts b/src/features/auth/authApi.ts index 36b25e1..3397447 100644 --- a/src/features/auth/authApi.ts +++ b/src/features/auth/authApi.ts @@ -1,45 +1,20 @@ import type { AuthResponse, RegisterData, AuthCredentials, User } from './authTypes' - -const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '/api' - -async function request(path: string, options: RequestInit = {}): Promise { - const token = localStorage.getItem('phyverse-token') - const headers: Record = { - 'Content-Type': 'application/json', - ...((options.headers as Record) ?? {}), - } - if (token) { - headers.Authorization = `Bearer ${token}` - } - - const response = await fetch(`${API_BASE}${path}`, { - ...options, - headers, - }) - - const data = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(data.error ?? `Request failed with status ${response.status}`) - } - - return data as T -} +import { apiRequest } from '@/shared/utils/apiClient' export async function register(data: RegisterData): Promise { - return request('/auth/register', { + return apiRequest('/auth/register', { method: 'POST', body: JSON.stringify(data), }) } export async function login(credentials: AuthCredentials): Promise { - return request('/auth/login', { + return apiRequest('/auth/login', { method: 'POST', body: JSON.stringify(credentials), }) } export async function fetchCurrentUser(): Promise<{ user: User }> { - return request<{ user: User }>('/auth/me') + return apiRequest<{ user: User }>('/auth/me') } diff --git a/src/features/cloud/cloudApi.ts b/src/features/cloud/cloudApi.ts index ed8230a..256fc90 100644 --- a/src/features/cloud/cloudApi.ts +++ b/src/features/cloud/cloudApi.ts @@ -1,37 +1,12 @@ import type { SceneMetadata, CloudScene } from '@/features/auth/authTypes' - -const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '/api' - -async function request(path: string, options: RequestInit = {}): Promise { - const token = localStorage.getItem('phyverse-token') - const headers: Record = { - 'Content-Type': 'application/json', - ...((options.headers as Record) ?? {}), - } - if (token) { - headers.Authorization = `Bearer ${token}` - } - - const response = await fetch(`${API_BASE}${path}`, { - ...options, - headers, - }) - - const data = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(data.error ?? `Request failed with status ${response.status}`) - } - - return data as T -} +import { apiRequest } from '@/shared/utils/apiClient' export async function listScenes(): Promise<{ scenes: SceneMetadata[] }> { - return request<{ scenes: SceneMetadata[] }>('/scenes') + return apiRequest<{ scenes: SceneMetadata[] }>('/scenes') } export async function getScene(id: string): Promise<{ scene: CloudScene }> { - return request<{ scene: CloudScene }>(`/scenes/${id}`) + return apiRequest<{ scene: CloudScene }>(`/scenes/${id}`) } export async function saveScene(payload: { @@ -40,7 +15,7 @@ export async function saveScene(payload: { data: unknown isPublic?: boolean }): Promise<{ scene: SceneMetadata }> { - return request<{ scene: SceneMetadata }>('/scenes', { + return apiRequest<{ scene: SceneMetadata }>('/scenes', { method: 'POST', body: JSON.stringify(payload), }) @@ -55,14 +30,14 @@ export async function updateScene( isPublic?: boolean } ): Promise<{ scene: SceneMetadata }> { - return request<{ scene: SceneMetadata }>(`/scenes/${id}`, { + return apiRequest<{ scene: SceneMetadata }>(`/scenes/${id}`, { method: 'PATCH', body: JSON.stringify(payload), }) } export async function deleteScene(id: string): Promise { - await request(`/scenes/${id}`, { + await apiRequest(`/scenes/${id}`, { method: 'DELETE', }) } diff --git a/src/features/experiments/mechanics/centripetalForce.ts b/src/features/experiments/mechanics/centripetalForce.ts index 80419b1..232d41c 100644 --- a/src/features/experiments/mechanics/centripetalForce.ts +++ b/src/features/experiments/mechanics/centripetalForce.ts @@ -1,5 +1,6 @@ import { registerExperiment } from '../registry' import { createJoint } from '@/features/physics/JointFactory' +import { magnitude, magnitudeXZ, distanceXZ } from '@/shared/utils/vectorMath' import type { ExperimentDefinition } from '@/shared/types/experiment' interface CentripetalData { @@ -127,8 +128,7 @@ const centripetalForceExperiment: ExperimentDefinition = { collect: (world) => { const ball = world.getBody('ball') if (!ball) return 0 - const v = ball.rigidBody.linvel() - return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + return magnitude(ball.rigidBody.linvel()) }, }, { @@ -139,11 +139,7 @@ const centripetalForceExperiment: ExperimentDefinition = { const pivot = world.getBody('pivot') const ball = world.getBody('ball') if (!pivot || !ball) return 0 - const pp = pivot.rigidBody.translation() - const bp = ball.rigidBody.translation() - const dx = bp.x - pp.x - const dz = bp.z - pp.z - return Math.sqrt(dx * dx + dz * dz) + return distanceXZ(ball.rigidBody.translation(), pivot.rigidBody.translation()) }, }, { @@ -154,13 +150,8 @@ const centripetalForceExperiment: ExperimentDefinition = { const pivot = world.getBody('pivot') const ball = world.getBody('ball') if (!pivot || !ball) return 0 - const pp = pivot.rigidBody.translation() - const bp = ball.rigidBody.translation() - const dx = bp.x - pp.x - const dz = bp.z - pp.z - const r = Math.sqrt(dx * dx + dz * dz) - const v = ball.rigidBody.linvel() - const speed = Math.sqrt(v.x * v.x + v.z * v.z) + const r = distanceXZ(ball.rigidBody.translation(), pivot.rigidBody.translation()) + const speed = magnitudeXZ(ball.rigidBody.linvel()) if (r < 0.01) return 0 return speed / r }, @@ -174,13 +165,8 @@ const centripetalForceExperiment: ExperimentDefinition = { const ball = world.getBody('ball') if (!pivot || !ball) return 0 const data = ball.rigidBody.userData as CentripetalData - const pp = pivot.rigidBody.translation() - const bp = ball.rigidBody.translation() - const dx = bp.x - pp.x - const dz = bp.z - pp.z - const r = Math.sqrt(dx * dx + dz * dz) - const v = ball.rigidBody.linvel() - const speed = Math.sqrt(v.x * v.x + v.z * v.z) + const r = distanceXZ(ball.rigidBody.translation(), pivot.rigidBody.translation()) + const speed = magnitudeXZ(ball.rigidBody.linvel()) if (r < 0.01) return 0 return (data.mass * speed * speed) / r }, diff --git a/src/features/experiments/mechanics/curvilinearMotion.ts b/src/features/experiments/mechanics/curvilinearMotion.ts index 9cbfa19..f198585 100644 --- a/src/features/experiments/mechanics/curvilinearMotion.ts +++ b/src/features/experiments/mechanics/curvilinearMotion.ts @@ -1,5 +1,6 @@ import { registerExperiment } from '../registry' import { createJoint } from '@/features/physics/JointFactory' +import { magnitude, magnitudeXZ, distanceXZ } from '@/shared/utils/vectorMath' import type { ExperimentDefinition } from '@/shared/types/experiment' const curvilinearMotionExperiment: ExperimentDefinition = { @@ -112,8 +113,7 @@ const curvilinearMotionExperiment: ExperimentDefinition = { collect: (world) => { const ball = world.getBody('ball') if (!ball) return 0 - const v = ball.rigidBody.linvel() - return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + return magnitude(ball.rigidBody.linvel()) }, }, { @@ -124,11 +124,7 @@ const curvilinearMotionExperiment: ExperimentDefinition = { const pivot = world.getBody('pivot') const ball = world.getBody('ball') if (!pivot || !ball) return 0 - const pp = pivot.rigidBody.translation() - const bp = ball.rigidBody.translation() - const dx = bp.x - pp.x - const dz = bp.z - pp.z - return Math.sqrt(dx * dx + dz * dz) + return distanceXZ(ball.rigidBody.translation(), pivot.rigidBody.translation()) }, }, { @@ -145,8 +141,8 @@ const curvilinearMotionExperiment: ExperimentDefinition = { const rx = bp.x - pp.x const rz = bp.z - pp.z const dot = rx * v.x + rz * v.z - const rMag = Math.sqrt(rx * rx + rz * rz) - const vMag = Math.sqrt(v.x * v.x + v.z * v.z) + const rMag = magnitudeXZ({ x: rx, z: rz }) + const vMag = magnitudeXZ(v) if (rMag < 0.01 || vMag < 0.01) return 0 const cosAngle = dot / (rMag * vMag) const clamped = Math.max(-1, Math.min(1, cosAngle)) diff --git a/src/features/experiments/mechanics/energyConservation.ts b/src/features/experiments/mechanics/energyConservation.ts index 9ed3250..b690cf7 100644 --- a/src/features/experiments/mechanics/energyConservation.ts +++ b/src/features/experiments/mechanics/energyConservation.ts @@ -1,4 +1,5 @@ import { registerExperiment } from '../registry' +import { magnitude } from '@/shared/utils/vectorMath' import type { ExperimentDefinition } from '@/shared/types/experiment' interface EnergyData { @@ -101,8 +102,7 @@ const energyConservationExperiment: ExperimentDefinition = { collect: (world) => { const ball = world.getBody('ball') if (!ball) return 0 - const v = ball.rigidBody.linvel() - return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + return magnitude(ball.rigidBody.linvel()) }, }, { @@ -113,8 +113,7 @@ const energyConservationExperiment: ExperimentDefinition = { const ball = world.getBody('ball') if (!ball) return 0 const data = ball.rigidBody.userData as EnergyData - const v = ball.rigidBody.linvel() - const speed = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + const speed = magnitude(ball.rigidBody.linvel()) return 0.5 * data.mass * speed * speed }, }, @@ -138,8 +137,7 @@ const energyConservationExperiment: ExperimentDefinition = { const ball = world.getBody('ball') if (!ball) return 0 const data = ball.rigidBody.userData as EnergyData - const v = ball.rigidBody.linvel() - const speed = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + const speed = magnitude(ball.rigidBody.linvel()) const h = ball.rigidBody.translation().y const ek = 0.5 * data.mass * speed * speed const ep = data.mass * 9.81 * h diff --git a/src/features/experiments/mechanics/freeFall.ts b/src/features/experiments/mechanics/freeFall.ts index 5c653b8..a1bdd2c 100644 --- a/src/features/experiments/mechanics/freeFall.ts +++ b/src/features/experiments/mechanics/freeFall.ts @@ -1,4 +1,5 @@ import { registerExperiment } from '../registry' +import { magnitude } from '@/shared/utils/vectorMath' import type { ExperimentDefinition } from '@/shared/types/experiment' const freeFallExperiment: ExperimentDefinition = { @@ -104,8 +105,7 @@ const freeFallExperiment: ExperimentDefinition = { collect: (world) => { const ball = world.getBody('ball') if (!ball) return 0 - const v = ball.rigidBody.linvel() - return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + return magnitude(ball.rigidBody.linvel()) }, }, ], diff --git a/src/features/experiments/mechanics/galileoIncline.ts b/src/features/experiments/mechanics/galileoIncline.ts index b234d4c..a2cac88 100644 --- a/src/features/experiments/mechanics/galileoIncline.ts +++ b/src/features/experiments/mechanics/galileoIncline.ts @@ -1,4 +1,5 @@ import { registerExperiment } from '../registry' +import { magnitude } from '@/shared/utils/vectorMath' import type { ExperimentDefinition } from '@/shared/types/experiment' const galileoInclineExperiment: ExperimentDefinition = { @@ -151,8 +152,7 @@ const galileoInclineExperiment: ExperimentDefinition = { collect: (world) => { const ball = world.getBody('ball') if (!ball) return 0 - const v = ball.rigidBody.linvel() - return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + return magnitude(ball.rigidBody.linvel()) }, }, { diff --git a/src/features/experiments/mechanics/motionComposition.ts b/src/features/experiments/mechanics/motionComposition.ts index 0126dad..c8a2397 100644 --- a/src/features/experiments/mechanics/motionComposition.ts +++ b/src/features/experiments/mechanics/motionComposition.ts @@ -1,4 +1,5 @@ import { registerExperiment } from '../registry' +import { magnitude } from '@/shared/utils/vectorMath' import type { ExperimentDefinition } from '@/shared/types/experiment' interface MotionData { @@ -138,8 +139,7 @@ const motionCompositionExperiment: ExperimentDefinition = { collect: (world) => { const block = world.getBody('wax-block') if (!block) return 0 - const v = block.rigidBody.linvel() - return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + return magnitude(block.rigidBody.linvel()) }, }, { diff --git a/src/features/experiments/mechanics/newtonSecondLaw.ts b/src/features/experiments/mechanics/newtonSecondLaw.ts index c083f0b..4a742ab 100644 --- a/src/features/experiments/mechanics/newtonSecondLaw.ts +++ b/src/features/experiments/mechanics/newtonSecondLaw.ts @@ -1,4 +1,5 @@ import { registerExperiment } from '../registry' +import { magnitude } from '@/shared/utils/vectorMath' import type { DataCollector, ExperimentDefinition } from '@/shared/types/experiment' import type { PhysicsWorld } from '@/features/physics/PhysicsWorld' @@ -11,8 +12,7 @@ function createAccelerationCollector(): DataCollector { collect: (world: PhysicsWorld) => { const cart = world.getBody('cart') if (!cart) return 0 - const v = cart.rigidBody.linvel() - const speed = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + const speed = magnitude(cart.rigidBody.linvel()) const dt = world.getTimestep() let accel = 0 if (lastSpeed !== null && dt > 0) { diff --git a/src/features/experiments/mechanics/tickerTimer.ts b/src/features/experiments/mechanics/tickerTimer.ts index 2d0b2c7..33c1e21 100644 --- a/src/features/experiments/mechanics/tickerTimer.ts +++ b/src/features/experiments/mechanics/tickerTimer.ts @@ -1,4 +1,5 @@ import { registerExperiment } from '../registry' +import { magnitude } from '@/shared/utils/vectorMath' import type { DataCollector, ExperimentDefinition } from '@/shared/types/experiment' import type { PhysicsWorld } from '@/features/physics/PhysicsWorld' @@ -11,8 +12,7 @@ function createAccelerationCollector(): DataCollector { collect: (world: PhysicsWorld) => { const cart = world.getBody('cart') if (!cart) return 0 - const v = cart.rigidBody.linvel() - const speed = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + const speed = magnitude(cart.rigidBody.linvel()) const dt = world.getTimestep() let accel = 0 if (lastSpeed !== null && dt > 0) { @@ -144,8 +144,7 @@ const tickerTimerExperiment: ExperimentDefinition = { collect: (world) => { const cart = world.getBody('cart') if (!cart) return 0 - const v = cart.rigidBody.linvel() - return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) + return magnitude(cart.rigidBody.linvel()) }, }, createAccelerationCollector(), diff --git a/src/shared/utils/apiClient.ts b/src/shared/utils/apiClient.ts new file mode 100644 index 0000000..a77b05b --- /dev/null +++ b/src/shared/utils/apiClient.ts @@ -0,0 +1,35 @@ +export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '/api' + +export const TOKEN_KEY = 'phyverse-token' + +export function getStoredToken(): string | null { + if (typeof window === 'undefined') return null + return window.localStorage.getItem(TOKEN_KEY) +} + +export function buildAuthHeaders(base: Record = {}): Record { + const headers: Record = { + 'Content-Type': 'application/json', + ...base, + } + const token = getStoredToken() + if (token) { + headers.Authorization = `Bearer ${token}` + } + return headers +} + +export async function apiRequest(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers: buildAuthHeaders((options.headers as Record) ?? {}), + }) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(data.error ?? `Request failed with status ${response.status}`) + } + + return data as T +} diff --git a/src/shared/utils/vectorMath.ts b/src/shared/utils/vectorMath.ts new file mode 100644 index 0000000..2d2e567 --- /dev/null +++ b/src/shared/utils/vectorMath.ts @@ -0,0 +1,22 @@ +export interface Vec3Like { + x: number + y: number + z: number +} + +/** Euclidean magnitude of a 3D vector. */ +export function magnitude(v: Vec3Like): number { + return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) +} + +/** Magnitude of a vector projected onto the horizontal (x-z) plane. */ +export function magnitudeXZ(v: { x: number; z: number }): number { + return Math.sqrt(v.x * v.x + v.z * v.z) +} + +/** Horizontal (x-z plane) distance between two points. */ +export function distanceXZ(a: { x: number; z: number }, b: { x: number; z: number }): number { + const dx = a.x - b.x + const dz = a.z - b.z + return Math.sqrt(dx * dx + dz * dz) +}