import type { AssetReplacementPoint, CameraPresetDefinition, ComponentHierarchyNode, ExplodedPartDefinition, GuidedTourStepDefinition, MachineDefinition, MachinePartDefinition, MachineViewStatePreset, MaterialTokenName, PrimitiveParameter, ProceduralPrimitiveDefinition, ProceduralPrimitiveKind, ThumbnailStrategy, Vector3Tuple, } from '../types'; interface PartInput { id: string; name: string; group: string; description: string; primitive: ProceduralPrimitiveDefinition; position: Vector3Tuple; rotation?: Vector3Tuple; scale?: Vector3Tuple; material: MaterialTokenName; explodedDirection?: Vector3Tuple; explode?: number; order?: number; label?: Vector3Tuple; labelOffset?: Vector3Tuple; notes?: readonly string[]; opacity?: number; visible?: boolean; selectable?: boolean; crossSectionBehavior?: MachinePartDefinition['crossSectionBehavior']; tags?: readonly string[]; } type MachineInput = Omit & { asset?: AssetReplacementPoint; defaultViewState?: MachineViewStatePreset; }; export function primitive( kind: ProceduralPrimitiveKind, parameters: Readonly>, detail: ProceduralPrimitiveDefinition['detail'] = 'medium', modellingHint?: string, ): ProceduralPrimitiveDefinition { return { kind, parameters, detail, modellingHint }; } export function makePart(input: PartInput): MachinePartDefinition { const exploded: ExplodedPartDefinition = { direction: input.explodedDirection ?? [0, 1, 0], distanceMultiplier: input.explode ?? 1, order: input.order ?? 0, }; return { id: input.id, name: input.name, group: input.group, description: input.description, engineeringNotes: input.notes ?? [], primitive: input.primitive, transform: { position: input.position, rotation: input.rotation ?? [0, 0, 0], scale: input.scale ?? [1, 1, 1], }, material: input.material, exploded, label: { anchor: input.label ?? input.position, offset: input.labelOffset ?? [0, 0.18, 0], text: input.name, visibleByDefault: true, }, defaultVisible: input.visible ?? true, defaultOpacity: input.opacity ?? 1, selectable: input.selectable ?? true, crossSectionBehavior: input.crossSectionBehavior ?? 'solid', tags: input.tags ?? [], }; } export function standardCameraPresets(radius = 6): readonly CameraPresetDefinition[] { const focalLength = 45; return [ { id: 'front', label: 'Front', position: [0, 0, radius], target: [0, 0, 0], up: [0, 1, 0], focalLength, description: 'Orthographic-style front elevation for part identification.', }, { id: 'back', label: 'Back', position: [0, 0, -radius], target: [0, 0, 0], up: [0, 1, 0], focalLength, description: 'Rear elevation showing shafts, housings, and outlets.', }, { id: 'left', label: 'Left', position: [-radius, 0, 0], target: [0, 0, 0], up: [0, 1, 0], focalLength, description: 'Left-hand side view for linkages and auxiliary components.', }, { id: 'right', label: 'Right', position: [radius, 0, 0], target: [0, 0, 0], up: [0, 1, 0], focalLength, description: 'Right-hand side view for drive and output components.', }, { id: 'top', label: 'Top', position: [0, radius, 0.01], target: [0, 0, 0], up: [0, 0, -1], focalLength, description: 'Plan view for gear paths, ports, and flow routing.', }, { id: 'isometric', label: 'Isometric', position: [radius * 0.62, radius * 0.46, radius * 0.62], target: [0, 0, 0], up: [0, 1, 0], focalLength: 38, description: 'Premium catalogue angle balancing depth, labels, and silhouettes.', }, ]; } export function thumbnail( title: string, heroPartIds: readonly string[], accentPartId?: string, cameraPresetId = 'isometric', ): ThumbnailStrategy { return { kind: 'procedural-snapshot', cameraPresetId, heroPartIds, accentPartId, background: 'dark-gradient', alt: `${title} procedural 3D preview`, notes: [ 'Generated from the procedural assembly so catalogue cards exist before GLB art is available.', 'When a final thumbnail render is supplied, set kind to static-image and keep hero part ids for fallback highlighting.', ], }; } export function makeAssetReplacement( slug: string, parts: readonly MachinePartDefinition[], ): AssetReplacementPoint { const partNodeMap = Object.fromEntries(parts.map((part) => [part.id, part.id])) as Record< string, string >; return { strategy: 'procedural-first-glb-replaceable', proceduralAssembly: 'registry-primitives', glbPath: `/assets/machines/${slug}/${slug}.glb`, previewImagePath: `/assets/machines/${slug}/preview.webp`, scaleMetersPerUnit: 1, upAxis: 'Y', rootNodeName: slug, partNodeMap, requiredPartNodeIds: parts.map((part) => part.id), optionalNodeIds: ['annotations', 'cutaway-shells', 'fluid-paths', 'measurement-rigs'], notes: [ 'Drop a GLB at the configured path and name mesh nodes with the registry part ids to replace procedural geometry without viewer code changes.', 'If an artist uses nested meshes, keep the top-level empty/group name equal to the part id so visibility, opacity, explode, labels, and selection keep working.', 'Procedural primitives remain the fallback for missing nodes and for low-bandwidth thumbnails.', ], }; } export function hierarchy( nodes: readonly ComponentHierarchyNode[], ): readonly ComponentHierarchyNode[] { return nodes; } export function groupNode( id: string, name: string, partIds: readonly string[], children?: readonly ComponentHierarchyNode[], ): ComponentHierarchyNode { return { id, name, partIds, children }; } export function tourStep( id: string, title: string, body: string, cameraPresetId: string, focusPartIds: readonly string[], options: Partial< Pick< GuidedTourStepDefinition, | 'durationSeconds' | 'highlightPartIds' | 'isolatePartIds' | 'explodeDistance' | 'playback' | 'rpm' | 'captionPlacement' > > = {}, ): GuidedTourStepDefinition { return { id, title, body, durationSeconds: options.durationSeconds ?? 6, cameraPresetId, focusPartIds, highlightPartIds: options.highlightPartIds ?? focusPartIds, isolatePartIds: options.isolatePartIds, explodeDistance: options.explodeDistance, playback: options.playback, rpm: options.rpm, captionPlacement: options.captionPlacement ?? 'bottom-right', }; } export function defaultViewState( cameraPresetId = 'isometric', displayMode: MachineViewStatePreset['displayMode'] = 'solid', ): MachineViewStatePreset { return { cameraPresetId, explodeDistance: 0, displayMode, crossSectionAxis: 'x', crossSectionOffset: 0, showLabels: true, }; } export function makeMachine(input: MachineInput): MachineDefinition { return { ...input, asset: input.asset ?? makeAssetReplacement(input.slug, input.parts), defaultViewState: input.defaultViewState ?? defaultViewState(), }; }