2026-04-22 15:47:27 -05:00
import '../style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { STLLoader } from 'three/addons/loaders/STLLoader.js'
2026-04-23 13:46:54 -05:00
import type { GeometryFile , HierarchyNode } from '../../server/services/stepConverter'
2026-04-22 15:47:27 -05:00
// ---- Data injected by viewer.ejs -----------------------------------------
declare const __STEPVIEW__ : {
modelId : number
fileType : 'step' | 'stp' | 'stl'
shareUrl : string
hasPdfs : boolean
hasGeometry : boolean // pre-processed geometry JSON exists (STEP/STP only)
}
// ---- Scene state ---------------------------------------------------------
2026-04-23 13:24:40 -05:00
let renderer : THREE.WebGLRenderer
let scene : THREE.Scene
let camera : THREE.PerspectiveCamera
let controls : OrbitControls
let viewingDist : number = 200 // updated by fitCamera; used by fog toggle
2026-04-22 15:47:27 -05:00
2026-04-23 13:46:54 -05:00
// Map from geometry-JSON mesh index → Three.js Mesh, used by the tree panel
const meshObjects = new Map < number , THREE.Mesh > ( )
2026-04-22 15:47:27 -05:00
// ---- UI helpers ----------------------------------------------------------
function setLoading ( msg : string ) {
const el = document . getElementById ( 'loading-msg' )
if ( el ) el . textContent = msg
}
function hideLoading() {
const overlay = document . getElementById ( 'loading-overlay' ) as HTMLElement
overlay . style . opacity = '0'
setTimeout ( ( ) = > { overlay . style . display = 'none' } , 300 )
}
function showError ( msg : string ) {
document . getElementById ( 'loading-overlay' ) ! . style . display = 'none'
const overlay = document . getElementById ( 'error-overlay' ) !
overlay . classList . remove ( 'hidden' )
overlay . classList . add ( 'flex' )
const msgEl = document . getElementById ( 'error-msg' )
if ( msgEl ) msgEl . textContent = msg
}
function showToast ( msg : string , duration = 2200 ) {
const toast = document . getElementById ( 'toast' ) !
const toastMsg = document . getElementById ( 'toast-msg' ) !
toastMsg . textContent = msg
toast . classList . remove ( 'opacity-0' , 'pointer-events-none' )
toast . classList . add ( 'opacity-100' )
setTimeout ( ( ) = > {
toast . classList . add ( 'opacity-0' , 'pointer-events-none' )
toast . classList . remove ( 'opacity-100' )
} , duration )
}
// ---- Scene setup ---------------------------------------------------------
function buildScene ( canvas : HTMLCanvasElement ) {
renderer = new THREE . WebGLRenderer ( { canvas , antialias : true , alpha : false } )
renderer . setPixelRatio ( Math . min ( window . devicePixelRatio , 2 ) )
renderer . setSize ( window . innerWidth , window . innerHeight )
renderer . setClearColor ( 0x0a0a0f , 1 )
renderer . shadowMap . enabled = true
renderer . shadowMap . type = THREE . PCFSoftShadowMap
renderer . outputColorSpace = THREE . SRGBColorSpace
renderer . toneMapping = THREE . ACESFilmicToneMapping
renderer . toneMappingExposure = 1.1
scene = new THREE . Scene ( )
camera = new THREE . PerspectiveCamera ( 45 , window . innerWidth / window . innerHeight , 0.001 , 10000 )
camera . position . set ( 5 , 3 , 8 )
2026-04-23 13:24:40 -05:00
// Lighting — no fog: FogExp2 with a fixed density darkens large models
// whose camera distance exceeds ~200 units, making them appear black.
const ambient = new THREE . AmbientLight ( 0xffffff , 0.9 )
2026-04-22 15:47:27 -05:00
scene . add ( ambient )
2026-04-23 13:24:40 -05:00
const key = new THREE . DirectionalLight ( 0xffffff , 1.8 )
key . position . set ( 1 , 2 , 1.5 )
key . castShadow = true
key . shadow . mapSize . set ( 2048 , 2048 )
key . shadow . camera . near = 0.1
key . shadow . camera . far = 500
scene . add ( key )
2026-04-22 15:47:27 -05:00
const fill = new THREE . DirectionalLight ( 0x8899cc , 0.4 )
2026-04-23 13:24:40 -05:00
fill . position . set ( - 2 , 0.5 , - 1 )
2026-04-22 15:47:27 -05:00
scene . add ( fill )
2026-04-23 13:24:40 -05:00
const rim = new THREE . DirectionalLight ( 0xffffff , 0.25 )
rim . position . set ( 0 , - 1 , - 2 )
2026-04-22 15:47:27 -05:00
scene . add ( rim )
controls = new OrbitControls ( camera , renderer . domElement )
controls . enableDamping = true
controls . dampingFactor = 0.06
controls . minDistance = 0.001
controls . maxDistance = 5000
controls . panSpeed = 0.8
controls . rotateSpeed = 0.6
controls . zoomSpeed = 1.2
window . addEventListener ( 'resize' , ( ) = > {
camera . aspect = window . innerWidth / window . innerHeight
camera . updateProjectionMatrix ( )
renderer . setSize ( window . innerWidth , window . innerHeight )
} )
}
// ---- Render loop ---------------------------------------------------------
function startRenderLoop() {
const clock = new THREE . Clock ( )
function tick() {
requestAnimationFrame ( tick )
controls . update ( )
renderer . render ( scene , camera )
void clock
}
tick ( )
}
// ---- Camera fit ----------------------------------------------------------
function fitCamera ( object : THREE . Object3D ) {
const box = new THREE . Box3 ( ) . setFromObject ( object )
const size = box . getSize ( new THREE . Vector3 ( ) )
const center = box . getCenter ( new THREE . Vector3 ( ) )
const maxDim = Math . max ( size . x , size . y , size . z )
if ( maxDim === 0 ) return
const fovRad = camera . fov * ( Math . PI / 180 )
const dist = ( maxDim / ( 2 * Math . tan ( fovRad / 2 ) ) ) * 1.6
2026-04-23 13:24:40 -05:00
viewingDist = dist
2026-04-22 15:47:27 -05:00
camera . near = maxDim * 0.0005
camera . far = maxDim * 200
camera . updateProjectionMatrix ( )
camera . position . set (
center . x + dist * 0.55 ,
center . y + dist * 0.35 ,
center . z + dist ,
)
camera . lookAt ( center )
controls . target . copy ( center )
controls . update ( )
}
// ---- Ground grid ---------------------------------------------------------
function addGrid ( object : THREE . Object3D ) {
const box = new THREE . Box3 ( ) . setFromObject ( object )
const size = box . getSize ( new THREE . Vector3 ( ) )
const center = box . getCenter ( new THREE . Vector3 ( ) )
const maxDim = Math . max ( size . x , size . z ) * 3
const grid = new THREE . GridHelper ( maxDim , 20 , 0x1a1a2a , 0x1a1a2a )
grid . position . set ( center . x , box . min . y - 0.001 , center . z )
scene . add ( grid )
}
// ---- Material factory ----------------------------------------------------
function makeMaterial ( color : [ number , number , number ] | null ) : THREE . MeshStandardMaterial {
const c = color
? new THREE . Color ( color [ 0 ] , color [ 1 ] , color [ 2 ] )
: new THREE . Color ( 0x8fa3b8 )
return new THREE . MeshStandardMaterial ( {
color : c ,
roughness : 0.45 ,
metalness : 0.25 ,
side : THREE.DoubleSide ,
} )
}
2026-04-23 09:06:40 -05:00
// ---- Binary decode helpers (geometry v2) ---------------------------------
function b64ToFloat32 ( b64 : string ) : Float32Array {
const bin = atob ( b64 )
const buf = new ArrayBuffer ( bin . length )
const u8 = new Uint8Array ( buf )
for ( let i = 0 ; i < bin . length ; i ++ ) u8 [ i ] = bin . charCodeAt ( i )
return new Float32Array ( buf )
}
function b64ToUint32 ( b64 : string ) : Uint32Array {
const bin = atob ( b64 )
const buf = new ArrayBuffer ( bin . length )
const u8 = new Uint8Array ( buf )
for ( let i = 0 ; i < bin . length ; i ++ ) u8 [ i ] = bin . charCodeAt ( i )
return new Uint32Array ( buf )
}
2026-04-22 15:47:27 -05:00
// ---- STEP/STP loader (geometry JSON, pre-processed server-side) ----------
async function loadStepGeometry ( ) : Promise < void > {
setLoading ( 'Fetching geometry…' )
const res = await fetch ( ` /files/geometry/ ${ __STEPVIEW__ . modelId } ` )
if ( ! res . ok ) {
const body = await res . json ( ) . catch ( ( ) = > ( { } ) ) as { processing? : boolean }
if ( body . processing ) {
throw new Error ( 'This model is still being processed. Please try again in a moment.' )
}
throw new Error ( ` Could not load geometry (HTTP ${ res . status } ) ` )
}
2026-04-23 09:06:40 -05:00
// Stream the response body so there is visible activity during large downloads
let text : string
if ( res . body ) {
const reader = res . body . getReader ( )
const chunks : Uint8Array [ ] = [ ]
let loaded = 0
while ( true ) {
const { done , value } = await reader . read ( )
if ( done ) break
chunks . push ( value )
loaded += value . byteLength
setLoading ( ` Downloading geometry… ${ Math . round ( loaded / 1024 ) } KB ` )
}
setLoading ( 'Parsing geometry…' )
text = new TextDecoder ( ) . decode (
chunks . reduce ( ( acc , c ) = > { const m = new Uint8Array ( acc . length + c . length ) ; m . set ( acc ) ; m . set ( c , acc . length ) ; return m } , new Uint8Array ( 0 ) )
)
} else {
text = await res . text ( )
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = JSON . parse ( text ) as GeometryFile & { version : number ; meshes : any [ ] }
2026-04-22 15:47:27 -05:00
if ( ! data . meshes || data . meshes . length === 0 ) {
throw new Error ( 'Geometry file contains no meshes.' )
}
2026-04-23 09:06:40 -05:00
setLoading ( 'Building 3D scene…' )
2026-04-22 15:47:27 -05:00
const group = new THREE . Group ( )
2026-04-23 09:06:40 -05:00
const isV2 = data . version >= 2
2026-04-22 15:47:27 -05:00
2026-04-23 13:46:54 -05:00
for ( let i = 0 ; i < data . meshes . length ; i ++ ) {
const mesh = data . meshes [ i ]
2026-04-22 15:47:27 -05:00
const geo = new THREE . BufferGeometry ( )
2026-04-23 09:06:40 -05:00
let positions : Float32Array
let normals : Float32Array | null
let indices : Uint32Array | null
if ( isV2 ) {
positions = b64ToFloat32 ( mesh . positions )
normals = mesh . normals ? b64ToFloat32 ( mesh . normals ) : null
indices = mesh . indices ? b64ToUint32 ( mesh . indices ) : null
} else {
// Legacy v1: plain number arrays
positions = new Float32Array ( mesh . positions as number [ ] )
normals = mesh . normals ? new Float32Array ( mesh . normals as number [ ] ) : null
indices = mesh . indices ? new Uint32Array ( mesh . indices as number [ ] ) : null
2026-04-22 15:47:27 -05:00
}
2026-04-23 09:06:40 -05:00
geo . setAttribute ( 'position' , new THREE . Float32BufferAttribute ( positions , 3 ) )
if ( normals && normals . length > 0 ) {
geo . setAttribute ( 'normal' , new THREE . Float32BufferAttribute ( normals , 3 ) )
2026-04-22 15:47:27 -05:00
}
2026-04-23 09:06:40 -05:00
if ( indices && indices . length > 0 ) {
geo . setIndex ( new THREE . Uint32BufferAttribute ( indices , 1 ) )
}
if ( ! normals || normals . length === 0 ) {
2026-04-22 15:47:27 -05:00
geo . computeVertexNormals ( )
}
2026-04-23 13:46:54 -05:00
const threeMesh = new THREE . Mesh ( geo , makeMaterial ( mesh . color ) )
meshObjects . set ( i , threeMesh )
group . add ( threeMesh )
2026-04-22 15:47:27 -05:00
}
scene . add ( group )
fitCamera ( group )
addGrid ( group )
2026-04-23 13:46:54 -05:00
2026-04-23 14:15:10 -05:00
// Diagnostics — always log tree state so browser console can confirm what arrived
console . log ( '[StepView] geometry version:' , data . version )
console . log ( '[StepView] mesh count:' , data . meshes . length )
console . log ( '[StepView] data.tree:' , data . tree )
2026-04-23 13:55:42 -05:00
const treeContent = document . getElementById ( 'tree-content' )
2026-04-23 14:15:10 -05:00
if ( ! treeContent ) return // not a STEP model or panel removed
if ( ! data . tree ) {
treeContent . innerHTML = '<p class="text-xs text-gray-500 px-4 py-3">No tree data — open the admin panel, edit this model, and click <strong class="text-gray-400">Retry Processing</strong> to rebuild geometry with tree support.</p>'
return
}
const nodeCount = countNodes ( data . tree )
console . log ( '[StepView] tree node count:' , nodeCount )
if ( nodeCount === 0 ) {
treeContent . innerHTML = '<p class="text-xs text-gray-500 px-4 py-3">This file has no named components in its hierarchy.</p>'
return
2026-04-23 13:55:42 -05:00
}
2026-04-23 14:15:10 -05:00
wireTreePanel ( data . tree )
}
function countNodes ( node : HierarchyNode ) : number {
return 1 + node . children . reduce ( ( acc , c ) = > acc + countNodes ( c ) , 0 )
2026-04-22 15:47:27 -05:00
}
// ---- STL loader (client-side, Three.js built-in) -------------------------
async function loadStl ( ) : Promise < void > {
setLoading ( 'Fetching STL file…' )
const loader = new STLLoader ( )
const geometry = await new Promise < THREE.BufferGeometry > ( ( resolve , reject ) = > {
loader . load (
` /files/model/ ${ __STEPVIEW__ . modelId } ` ,
resolve ,
( xhr ) = > {
if ( xhr . total ) {
const pct = Math . round ( ( xhr . loaded / xhr . total ) * 100 )
setLoading ( ` Downloading STL… ${ pct } % ` )
}
} ,
reject ,
)
} )
setLoading ( 'Building 3D scene…' )
geometry . computeVertexNormals ( )
// Center geometry at origin
geometry . computeBoundingBox ( )
const center = new THREE . Vector3 ( )
geometry . boundingBox ! . getCenter ( center )
geometry . translate ( - center . x , - center . y , - center . z )
const mesh = new THREE . Mesh ( geometry , makeMaterial ( null ) )
mesh . castShadow = true
mesh . receiveShadow = true
scene . add ( mesh )
fitCamera ( mesh )
addGrid ( mesh )
}
2026-04-23 13:46:54 -05:00
// ---- Model tree ----------------------------------------------------------
function setNodeVisible ( node : HierarchyNode , visible : boolean ) {
for ( const idx of node . meshes ) {
const m = meshObjects . get ( idx )
if ( m ) m . visible = visible
}
for ( const child of node . children ) setNodeVisible ( child , visible )
}
function buildTreeNode ( node : HierarchyNode , depth : number ) : HTMLElement {
const label = node . name . trim ( ) || 'Solid'
const hasChildren = node . children . length > 0
const wrapper = document . createElement ( 'div' )
const row = document . createElement ( 'div' )
row . className = 'flex items-center gap-1 py-1 rounded-lg hover:bg-white/5 group select-none'
row . style . paddingLeft = ` ${ 6 + depth * 14 } px `
row . style . paddingRight = '6px'
// Chevron
const chevron = document . createElement ( 'span' )
chevron . className = ` w-3.5 h-3.5 shrink-0 text-gray-600 transition-transform duration-150 ${ hasChildren ? 'cursor-pointer' : 'invisible' } `
chevron . innerHTML = ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg> `
// Eye toggle
const EYE_ON = ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg> `
const EYE_OFF = ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"/></svg> `
const eyeBtn = document . createElement ( 'button' )
eyeBtn . className = 'w-3.5 h-3.5 shrink-0 text-gray-500 hover:text-white transition-colors opacity-0 group-hover:opacity-100'
eyeBtn . title = 'Toggle visibility'
eyeBtn . innerHTML = EYE_ON
let visible = true
eyeBtn . addEventListener ( 'click' , ( e ) = > {
e . stopPropagation ( )
visible = ! visible
eyeBtn . innerHTML = visible ? EYE_ON : EYE_OFF
eyeBtn . style . opacity = visible ? '' : '1'
eyeBtn . style . color = visible ? '' : 'rgb(248 113 113)'
nameEl . style . opacity = visible ? '' : '0.3'
setNodeVisible ( node , visible )
} )
// Name
const nameEl = document . createElement ( 'span' )
nameEl . className = 'text-xs text-gray-300 truncate flex-1'
nameEl . textContent = label
row . append ( chevron , eyeBtn , nameEl )
wrapper . append ( row )
if ( hasChildren ) {
const childrenEl = document . createElement ( 'div' )
for ( const child of node . children ) {
childrenEl . append ( buildTreeNode ( child , depth + 1 ) )
}
let expanded = true
chevron . style . transform = 'rotate(90deg)'
const toggle = ( ) = > {
expanded = ! expanded
chevron . style . transform = expanded ? 'rotate(90deg)' : ''
childrenEl . style . display = expanded ? '' : 'none'
}
chevron . addEventListener ( 'click' , ( e ) = > { e . stopPropagation ( ) ; toggle ( ) } )
row . addEventListener ( 'click' , toggle )
wrapper . append ( childrenEl )
}
return wrapper
}
function wireTreePanel ( root : HierarchyNode ) {
const content = document . getElementById ( 'tree-content' )
if ( ! content ) return
2026-04-23 13:55:42 -05:00
// Skip an unnamed root wrapper — go straight to its children
2026-04-23 13:46:54 -05:00
const topNodes = ( ! root . name && root . children . length > 0 ) ? root . children : [ root ]
content . innerHTML = ''
for ( const node of topNodes ) {
content . append ( buildTreeNode ( node , 0 ) )
}
}
2026-04-22 15:47:27 -05:00
// ---- Viewer toolbar ------------------------------------------------------
function wireToolbar() {
2026-04-23 13:55:42 -05:00
// Model tree panel toggle (always wired; content populated after geometry loads)
const treePanel = document . getElementById ( 'tree-panel' )
const treeBtn = document . getElementById ( 'tree-toggle-btn' )
const treeClose = document . getElementById ( 'tree-close-btn' )
if ( treePanel ) {
treeBtn ? . addEventListener ( 'click' , ( ) = > {
const closing = ! treePanel . classList . contains ( 'closed' )
treePanel . classList . toggle ( 'closed' )
treeBtn . classList . toggle ( 'text-accent' , ! closing )
} )
treeClose ? . addEventListener ( 'click' , ( ) = > {
treePanel . classList . add ( 'closed' )
treeBtn ? . classList . remove ( 'text-accent' )
} )
}
2026-04-22 15:47:27 -05:00
// Copy-link
const copyBtn = document . getElementById ( 'copy-link-btn' ) as HTMLButtonElement | null
if ( copyBtn ) {
copyBtn . addEventListener ( 'click' , async ( ) = > {
try {
await navigator . clipboard . writeText ( __STEPVIEW__ . shareUrl )
} catch {
const ta = Object . assign ( document . createElement ( 'textarea' ) , {
value : __STEPVIEW__.shareUrl ,
style : 'position:fixed;opacity:0' ,
} )
document . body . appendChild ( ta )
ta . select ( )
document . execCommand ( 'copy' )
document . body . removeChild ( ta )
}
const orig = copyBtn . innerHTML
copyBtn . textContent = '✓ Copied!'
setTimeout ( ( ) = > { copyBtn . innerHTML = orig } , 2000 )
} )
}
// Reset camera
const resetBtn = document . getElementById ( 'reset-camera-btn' )
if ( resetBtn ) {
resetBtn . addEventListener ( 'click' , ( ) = > {
// Re-fit camera to scene objects (exclude grid)
const objects = scene . children . filter ( c = > ! ( c instanceof THREE . GridHelper ) )
const group = new THREE . Group ( )
objects . forEach ( o = > group . add ( o . clone ( ) ) )
if ( group . children . length ) fitCamera ( group )
} )
}
2026-04-23 13:24:40 -05:00
// Fog toggle — off by default; density scaled to the loaded model's viewing distance
let fogOn = false
const fogBtn = document . getElementById ( 'fog-btn' )
if ( fogBtn ) {
fogBtn . addEventListener ( 'click' , ( ) = > {
fogOn = ! fogOn
scene . fog = fogOn
2026-04-23 13:29:11 -05:00
? new THREE . FogExp2 ( 0x0a0a0f , 0.5 / viewingDist )
2026-04-23 13:24:40 -05:00
: null
fogBtn . classList . toggle ( 'text-accent' , fogOn )
} )
}
2026-04-22 15:47:27 -05:00
// Wireframe toggle
let wireframe = false
const wireBtn = document . getElementById ( 'wireframe-btn' )
if ( wireBtn ) {
wireBtn . addEventListener ( 'click' , ( ) = > {
wireframe = ! wireframe
scene . traverse ( obj = > {
if ( obj instanceof THREE . Mesh && obj . material instanceof THREE . MeshStandardMaterial ) {
obj . material . wireframe = wireframe
}
} )
wireBtn . classList . toggle ( 'text-accent' , wireframe )
} )
}
// PDF panel
const pdfToggle = document . getElementById ( 'pdf-toggle-btn' )
const pdfPanel = document . getElementById ( 'pdf-panel' )
const pdfClose = document . getElementById ( 'pdf-close-btn' )
if ( pdfToggle && pdfPanel ) {
pdfToggle . addEventListener ( 'click' , ( ) = > pdfPanel . classList . toggle ( 'closed' ) )
}
if ( pdfClose && pdfPanel ) {
pdfClose . addEventListener ( 'click' , ( ) = > pdfPanel . classList . add ( 'closed' ) )
}
// PDF tabs
document . querySelectorAll < HTMLButtonElement > ( '.pdf-tab' ) . forEach ( tab = > {
tab . addEventListener ( 'click' , ( ) = > {
const idx = tab . dataset . tab
document . querySelectorAll ( '.pdf-tab' ) . forEach ( t = > {
t . classList . remove ( 'border-accent' , 'text-white' )
t . classList . add ( 'border-transparent' , 'text-gray-500' )
} )
tab . classList . add ( 'border-accent' , 'text-white' )
tab . classList . remove ( 'border-transparent' , 'text-gray-500' )
document . querySelectorAll < HTMLElement > ( '.pdf-frame' ) . forEach ( f = > {
f . classList . toggle ( 'hidden' , f . dataset . frame !== idx )
} )
} )
} )
}
// ---- Boot ----------------------------------------------------------------
async function boot() {
const canvas = document . getElementById ( 'viewer-canvas' ) as HTMLCanvasElement
try {
buildScene ( canvas )
startRenderLoop ( )
wireToolbar ( )
if ( __STEPVIEW__ . fileType === 'stl' ) {
await loadStl ( )
} else {
// STEP / STP
if ( ! __STEPVIEW__ . hasGeometry ) {
throw new Error (
'This model has not finished processing yet. ' +
'Please check back shortly or contact your administrator.'
)
}
await loadStepGeometry ( )
}
hideLoading ( )
} catch ( err ) {
console . error ( '[StepView]' , err )
showError ( err instanceof Error ? err.message : String ( err ) )
}
}
boot ( )
export { }