export type FemNode = { id: number; position: [number, number, number] }
export type FemMaterial = { id: string; youngsModulus: number; poissonRatio: number }
export type FemLoad = { nodeId: number; force: number }
export type FemResult = { nodeId: number; displacement: number; stress: number }
export type FemNodeSet = { id: string; nodeIds: number[]; role: 'constraint' | 'load' | 'result' }
export type FemResultField = { name: 'displacement' | 'stress'; unit: 'mm' | 'MPa'; values: Array<{ nodeId: number; value: number }> }
export type FemSnapshot = { id: string; label: string; material?: FemMaterial; nodes: FemNode[]; nodeSets: FemNodeSet[]; fixedNodeIds: number[]; loads: FemLoad[]; results: FemResult[]; resultFields: FemResultField[]; solverStrategy: 'local-reference' | 'remote-required'; status: 'draft' | 'solved' | 'invalid'; version: number }
export type FemApi = { snapshot(): FemSnapshot; setMaterial(material: FemMaterial): FemMaterial; setMesh(nodes: FemNode[]): FemSnapshot; addNodeSet(set: FemNodeSet): FemNodeSet; fixNode(nodeId: number): FemSnapshot; addLoad(load: FemLoad): FemLoad; solve(input: { length: number; area: number }): FemSnapshot; exportResultsCsv(): string }
const finite = (value: number, label: string) => { if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`) }
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
export const createFemAnalysis = (id = 'fem', label = 'FEM analysis'): FemApi => { let material: FemMaterial | undefined; let nodes: FemNode[] = []; const nodeSets = new Map<string, FemNodeSet>(); const fixed = new Set<number>(); const loads: FemLoad[] = []; let results: FemResult[] = []; let status: FemSnapshot['status'] = 'draft'; let version = 0; const fields = (): FemResultField[] => results.length ? [{ name: 'displacement', unit: 'mm', values: results.map((entry) => ({ nodeId: entry.nodeId, value: entry.displacement })) }, { name: 'stress', unit: 'MPa', values: results.map((entry) => ({ nodeId: entry.nodeId, value: entry.stress })) }] : []; const snapshot = (): FemSnapshot => ({ id, label, material: material ? clone(material) : undefined, nodes: clone(nodes), nodeSets: [...nodeSets.values()].map(clone), fixedNodeIds: [...fixed].sort((a, b) => a - b), loads: clone(loads), results: clone(results), resultFields: fields(), solverStrategy: 'local-reference', status, version }); const node = (nodeId: number) => { const value = nodes.find((entry) => entry.id === nodeId); if (!value) throw new RangeError(`FEM node does not exist: ${nodeId}`); return value }; const setMaterial = (input: FemMaterial) => { if (!input.id.trim()) throw new RangeError('FEM material id is required.'); if (!Number.isFinite(input.youngsModulus) || input.youngsModulus <= 0) throw new RangeError('FEM Young modulus must be positive.'); if (!Number.isFinite(input.poissonRatio) || input.poissonRatio < 0 || input.poissonRatio >= 0.5) throw new RangeError('FEM Poisson ratio must be in [0,0.5).'); material = clone(input); status = 'draft'; version += 1; return clone(input) }; const setMesh = (input: FemNode[]) => { if (input.length < 2 || new Set(input.map((entry) => entry.id)).size !== input.length) throw new RangeError('FEM mesh needs at least two unique nodes.'); input.forEach((entry) => { if (!Number.isSafeInteger(entry.id)) throw new RangeError('FEM node id must be an integer.'); entry.position.forEach((value) => finite(value, 'FEM node position')) }); nodes = clone(input); nodeSets.clear(); fixed.clear(); loads.splice(0); results = []; status = 'draft'; version += 1; return snapshot() }; const addNodeSet = (input: FemNodeSet) => { if (!input.id.trim() || nodeSets.has(input.id) || input.nodeIds.length === 0) throw new RangeError(`FEM node set is invalid or already exists: ${input.id}`); input.nodeIds.forEach(node); const value = { ...input, nodeIds: [...new Set(input.nodeIds)].sort((a, b) => a - b) }; nodeSets.set(value.id, value); version += 1; return clone(value) }; const fixNode = (nodeId: number) => { node(nodeId); fixed.add(nodeId); status = 'draft'; version += 1; return snapshot() }; const addLoad = (input: FemLoad) => { node(input.nodeId); finite(input.force, 'FEM force'); loads.push(clone(input)); status = 'draft'; version += 1; return clone(input) }; const solve = (input: { length: number; area: number }) => { if (!material || nodes.length < 2 || fixed.size === 0 || loads.length === 0) { status = 'invalid'; results = []; version += 1; return snapshot() } if (![input.length, input.area].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError('FEM reference length and area must be positive.'); const totalForce = loads.reduce((sum, entry) => sum + entry.force, 0); const displacement = totalForce * input.length / (input.area * material.youngsModulus); const stress = totalForce / input.area; results = nodes.map((entry) => ({ nodeId: entry.id, displacement: fixed.has(entry.id) ? 0 : displacement, stress: fixed.has(entry.id) ? 0 : stress })); status = 'solved'; version += 1; return snapshot() }; const exportResultsCsv = () => [['nodeId', 'displacement', 'stress'], ...results.map((entry) => [String(entry.nodeId), entry.displacement.toFixed(9), entry.stress.toFixed(9)])].map((row) => row.join(',')).join('\n') + (results.length ? '\n' : ''); return { snapshot, setMaterial, setMesh, addNodeSet, fixNode, addLoad, solve, exportResultsCsv } }
