Spaces:
Sleeping
Sleeping
| import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; | |
| import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; | |
| const VIEW_LABELS = ['Top', 'Left', 'Front', 'Bottom', 'Right', 'Back']; | |
| const DAY_MS = 24 * 60 * 60 * 1000; | |
| const VIEW_SPECS = [ | |
| { label: 'Top', dir: new THREE.Vector3(0, 1, 0), up: new THREE.Vector3(0, 0, 1) }, | |
| { label: 'Left', dir: new THREE.Vector3(1, 0, 0), up: new THREE.Vector3(0, 1, 0) }, | |
| { label: 'Front', dir: new THREE.Vector3(0, 0, 1), up: new THREE.Vector3(0, 1, 0) }, | |
| { label: 'Bottom', dir: new THREE.Vector3(0, -1, 0), up: new THREE.Vector3(0, 0, 1) }, | |
| { label: 'Right', dir: new THREE.Vector3(-1, 0, 0), up: new THREE.Vector3(0, 1, 0) }, | |
| { label: 'Back', dir: new THREE.Vector3(0, 0, -1), up: new THREE.Vector3(0, 1, 0) }, | |
| ]; | |
| function applyDefaultMaterial(object) { | |
| const meshMaterial = new THREE.MeshStandardMaterial({ | |
| color: 0xe3e9f3, | |
| roughness: 0.58, | |
| metalness: 0.08, | |
| side: THREE.DoubleSide, | |
| }); | |
| const lineMaterial = new THREE.LineBasicMaterial({ color: 0x9bdcff }); | |
| const pointMaterial = new THREE.PointsMaterial({ color: 0x9bdcff, size: 0.02 }); | |
| let renderableCount = 0; | |
| object.traverse((child) => { | |
| if (child.isMesh) { | |
| child.material = meshMaterial; | |
| child.castShadow = true; | |
| child.receiveShadow = true; | |
| child.geometry?.computeVertexNormals(); | |
| renderableCount += 1; | |
| } | |
| if (child.isLine || child.isLineSegments) { | |
| child.material = lineMaterial; | |
| renderableCount += 1; | |
| } | |
| if (child.isPoints) { | |
| child.material = pointMaterial; | |
| renderableCount += 1; | |
| } | |
| }); | |
| return renderableCount; | |
| } | |
| function disposeObject(object) { | |
| object?.traverse((child) => { | |
| if (!child.isMesh && !child.isLine && !child.isLineSegments && !child.isPoints) return; | |
| child.geometry?.dispose(); | |
| if (Array.isArray(child.material)) { | |
| child.material.forEach((material) => material.dispose()); | |
| } else { | |
| child.material?.dispose(); | |
| } | |
| }); | |
| } | |
| function getMeshes(object) { | |
| const meshes = []; | |
| object.traverse((child) => { | |
| if (child.isMesh && child.geometry?.getAttribute('position')) { | |
| meshes.push(child); | |
| } | |
| }); | |
| return meshes; | |
| } | |
| function getPrimaryMesh(object) { | |
| return getMeshes(object).reduce((primary, mesh) => { | |
| const count = mesh.geometry.getAttribute('position')?.count || 0; | |
| const primaryCount = primary?.geometry.getAttribute('position')?.count || 0; | |
| return count > primaryCount ? mesh : primary; | |
| }, null); | |
| } | |
| function getPrimaryBounds(object) { | |
| const box = new THREE.Box3().setFromObject(object); | |
| if (box.isEmpty()) { | |
| throw new Error('OBJに表示可能なジオメトリがありません。'); | |
| } | |
| const size = box.getSize(new THREE.Vector3()); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| const maxDimension = Math.max(size.x, size.y, size.z) || 1; | |
| return { box, size, center, maxDimension }; | |
| } | |
| function getCanvasAspect(renderer) { | |
| const width = Math.max(1, renderer?.domElement?.width || 1); | |
| const height = Math.max(1, renderer?.domElement?.height || 1); | |
| return width / height; | |
| } | |
| function setOrthographicFrame(camera, center, maxDimension, aspect) { | |
| const viewHeight = Math.max(1, maxDimension * 1.45); | |
| const viewWidth = viewHeight * Math.max(0.1, aspect || 1); | |
| camera.left = -viewWidth / 2; | |
| camera.right = viewWidth / 2; | |
| camera.top = viewHeight / 2; | |
| camera.bottom = -viewHeight / 2; | |
| camera.near = 0.001; | |
| camera.far = Math.max(1000, maxDimension * 80); | |
| camera.zoom = 1; | |
| camera.position.set(center.x, center.y, center.z + Math.max(10, maxDimension * 8)); | |
| camera.lookAt(center); | |
| camera.updateProjectionMatrix(); | |
| } | |
| function getProjectedBounds(object, camera) { | |
| object.updateMatrixWorld(true); | |
| camera.updateMatrixWorld(true); | |
| const worldPosition = new THREE.Vector3(); | |
| const projected = new THREE.Vector3(); | |
| const bounds = { | |
| minX: Infinity, | |
| maxX: -Infinity, | |
| minY: Infinity, | |
| maxY: -Infinity, | |
| count: 0, | |
| }; | |
| getMeshes(object).forEach((mesh) => { | |
| const position = mesh.geometry.getAttribute('position'); | |
| const step = Math.max(1, Math.floor(position.count / 8000)); | |
| for (let index = 0; index < position.count; index += step) { | |
| worldPosition.fromBufferAttribute(position, index).applyMatrix4(mesh.matrixWorld); | |
| projected.copy(worldPosition).project(camera); | |
| if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) continue; | |
| bounds.minX = Math.min(bounds.minX, projected.x); | |
| bounds.maxX = Math.max(bounds.maxX, projected.x); | |
| bounds.minY = Math.min(bounds.minY, projected.y); | |
| bounds.maxY = Math.max(bounds.maxY, projected.y); | |
| bounds.count += 1; | |
| } | |
| }); | |
| if (bounds.count === 0) return null; | |
| return { | |
| ...bounds, | |
| centerX: (bounds.minX + bounds.maxX) / 2, | |
| centerY: (bounds.minY + bounds.maxY) / 2, | |
| width: bounds.maxX - bounds.minX, | |
| height: bounds.maxY - bounds.minY, | |
| }; | |
| } | |
| function panOrthographicCamera(camera, controls, ndcX, ndcY) { | |
| const viewWidth = (camera.right - camera.left) / camera.zoom; | |
| const viewHeight = (camera.top - camera.bottom) / camera.zoom; | |
| const cameraRight = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion); | |
| const cameraUp = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion); | |
| const shift = cameraRight | |
| .multiplyScalar((ndcX * viewWidth) / 2) | |
| .add(cameraUp.multiplyScalar((ndcY * viewHeight) / 2)); | |
| camera.position.add(shift); | |
| controls.target.add(shift); | |
| camera.lookAt(controls.target); | |
| camera.updateMatrixWorld(true); | |
| } | |
| function refineOrthographicFit(camera, controls, object) { | |
| let projectedBounds = null; | |
| for (let iteration = 0; iteration < 4; iteration += 1) { | |
| projectedBounds = getProjectedBounds(object, camera); | |
| if (!projectedBounds) break; | |
| if (Math.abs(projectedBounds.centerX) > 0.01 || Math.abs(projectedBounds.centerY) > 0.01) { | |
| panOrthographicCamera(camera, controls, projectedBounds.centerX, projectedBounds.centerY); | |
| } | |
| } | |
| controls.update(); | |
| return projectedBounds; | |
| } | |
| function getCanvasPixelBounds(renderer) { | |
| const sourceCanvas = renderer?.domElement; | |
| if (!sourceCanvas?.width || !sourceCanvas?.height) return null; | |
| const sampleWidth = Math.min(420, sourceCanvas.width); | |
| const sampleHeight = Math.max(1, Math.round((sourceCanvas.height / sourceCanvas.width) * sampleWidth)); | |
| const sampleCanvas = document.createElement('canvas'); | |
| sampleCanvas.width = sampleWidth; | |
| sampleCanvas.height = sampleHeight; | |
| const context = sampleCanvas.getContext('2d', { willReadFrequently: true }); | |
| if (!context) return null; | |
| context.drawImage(sourceCanvas, 0, 0, sampleWidth, sampleHeight); | |
| const { data } = context.getImageData(0, 0, sampleWidth, sampleHeight); | |
| const bounds = { | |
| minX: sampleWidth, | |
| maxX: -1, | |
| minY: sampleHeight, | |
| maxY: -1, | |
| count: 0, | |
| }; | |
| for (let y = 0; y < sampleHeight; y += 1) { | |
| for (let x = 0; x < sampleWidth; x += 1) { | |
| const offset = (y * sampleWidth + x) * 4; | |
| const red = data[offset]; | |
| const green = data[offset + 1]; | |
| const blue = data[offset + 2]; | |
| const alpha = data[offset + 3]; | |
| const colorDistance = Math.abs(red - 17) + Math.abs(green - 24) + Math.abs(blue - 39); | |
| if (alpha > 0 && red + green + blue > 100 && colorDistance > 70) { | |
| bounds.minX = Math.min(bounds.minX, x); | |
| bounds.maxX = Math.max(bounds.maxX, x); | |
| bounds.minY = Math.min(bounds.minY, y); | |
| bounds.maxY = Math.max(bounds.maxY, y); | |
| bounds.count += 1; | |
| } | |
| } | |
| } | |
| if (bounds.count < 20) return null; | |
| return { | |
| ...bounds, | |
| width: bounds.maxX - bounds.minX + 1, | |
| height: bounds.maxY - bounds.minY + 1, | |
| centerX: (bounds.minX + bounds.maxX + 1) / 2, | |
| centerY: (bounds.minY + bounds.maxY + 1) / 2, | |
| sampleWidth, | |
| sampleHeight, | |
| }; | |
| } | |
| function refineCanvasFit(renderer, camera, controls, renderFrame) { | |
| let pixelBounds = null; | |
| for (let iteration = 0; iteration < 5; iteration += 1) { | |
| renderFrame(); | |
| pixelBounds = getCanvasPixelBounds(renderer); | |
| if (!pixelBounds) break; | |
| const centerNdcX = (pixelBounds.centerX / pixelBounds.sampleWidth) * 2 - 1; | |
| const centerNdcY = -((pixelBounds.centerY / pixelBounds.sampleHeight) * 2 - 1); | |
| if (Math.abs(centerNdcX) > 0.015 || Math.abs(centerNdcY) > 0.015) { | |
| panOrthographicCamera(camera, controls, centerNdcX * 0.55, centerNdcY * 0.55); | |
| } | |
| } | |
| controls.update(); | |
| return pixelBounds; | |
| } | |
| function buildCenteredObject(object, fileName) { | |
| object.updateMatrixWorld(true); | |
| const sourceBox = new THREE.Box3().setFromObject(object); | |
| if (sourceBox.isEmpty()) { | |
| throw new Error('OBJに表示可能なジオメトリがありません。'); | |
| } | |
| const center = sourceBox.getCenter(new THREE.Vector3()); | |
| const group = new THREE.Group(); | |
| group.name = fileName || 'OBJ model'; | |
| object.traverse((child) => { | |
| if (!child.geometry || (!child.isMesh && !child.isLine && !child.isLineSegments && !child.isPoints)) { | |
| return; | |
| } | |
| const geometry = child.geometry.clone(); | |
| geometry.applyMatrix4(child.matrixWorld); | |
| geometry.translate(-center.x, -center.y, -center.z); | |
| geometry.computeBoundingBox(); | |
| geometry.computeBoundingSphere(); | |
| if (child.isMesh) { | |
| geometry.computeVertexNormals(); | |
| group.add(new THREE.Mesh(geometry)); | |
| return; | |
| } | |
| if (child.isLineSegments) { | |
| group.add(new THREE.LineSegments(geometry)); | |
| return; | |
| } | |
| if (child.isLine) { | |
| group.add(new THREE.Line(geometry)); | |
| return; | |
| } | |
| if (child.isPoints) { | |
| group.add(new THREE.Points(geometry)); | |
| } | |
| }); | |
| if (group.children.length === 0) { | |
| throw new Error('OBJを解析しましたが、表示可能なmesh/line/pointsがありません。'); | |
| } | |
| const finalBox = new THREE.Box3().setFromObject(group); | |
| const finalCenter = finalBox.getCenter(new THREE.Vector3()); | |
| getMeshes(group).forEach((mesh) => { | |
| mesh.geometry.translate(-finalCenter.x, -finalCenter.y, -finalCenter.z); | |
| mesh.geometry.computeBoundingBox(); | |
| mesh.geometry.computeBoundingSphere(); | |
| }); | |
| return group; | |
| } | |
| function parseObjText(objText, fileName) { | |
| const sample = objText.trim().slice(0, 120).toLowerCase(); | |
| if (sample.startsWith('<!doctype') || sample.startsWith('<html') || sample.startsWith('{')) { | |
| throw new Error('OBJではないレスポンスを受信しました。APIレスポンスを確認してください。'); | |
| } | |
| const loader = new OBJLoader(); | |
| const object = buildCenteredObject(loader.parse(objText), fileName); | |
| const renderableCount = applyDefaultMaterial(object); | |
| if (renderableCount === 0) { | |
| throw new Error('OBJを解析しましたが、表示可能なmesh/line/pointsがありません。'); | |
| } | |
| return object; | |
| } | |
| function getPatientIdFromFileName(value) { | |
| return value?.match(/^ldsm_fit_original_(\d{5})_/i)?.[1] || ''; | |
| } | |
| function parseStudyDate(value) { | |
| if (!/^\d{8}$/.test(value || '')) return null; | |
| const year = Number(value.slice(0, 4)); | |
| const month = Number(value.slice(4, 6)); | |
| const day = Number(value.slice(6, 8)); | |
| const timestamp = Date.UTC(year, month - 1, day); | |
| return Number.isFinite(timestamp) ? timestamp : null; | |
| } | |
| function getFrameStudyDate(frame) { | |
| return frame?.file?.studyDate || frame?.file?.name?.match(/_(\d{8})\.obj$/i)?.[1] || ''; | |
| } | |
| function prepareMorphTimeline(frames) { | |
| const prepared = frames | |
| .map((frame, index) => ({ | |
| ...frame, | |
| originalIndex: index, | |
| studyDate: getFrameStudyDate(frame), | |
| })) | |
| .sort((a, b) => { | |
| const dateCompare = a.studyDate.localeCompare(b.studyDate); | |
| return dateCompare || a.originalIndex - b.originalIndex; | |
| }); | |
| const firstTimestamp = parseStudyDate(prepared[0]?.studyDate); | |
| const offsets = prepared.map((frame, index) => { | |
| const timestamp = parseStudyDate(frame.studyDate); | |
| if (firstTimestamp === null || timestamp === null) return index; | |
| return Math.max(0, Math.round((timestamp - firstTimestamp) / DAY_MS)); | |
| }); | |
| const lastDay = Math.max(1, offsets[offsets.length - 1] || prepared.length - 1); | |
| return { frames: prepared, offsets, lastDay }; | |
| } | |
| function getTimelineState(day, offsets) { | |
| if (!offsets.length) { | |
| return { | |
| progress: 0, | |
| meshIndex: 0, | |
| meshCount: 0, | |
| day: 0, | |
| }; | |
| } | |
| const maxDay = offsets[offsets.length - 1] || offsets.length - 1; | |
| const clampedDay = Math.max(0, Math.min(maxDay, day)); | |
| let segmentIndex = 0; | |
| while (segmentIndex < offsets.length - 2 && clampedDay > offsets[segmentIndex + 1]) { | |
| segmentIndex += 1; | |
| } | |
| const fromDay = offsets[segmentIndex] ?? 0; | |
| const toDay = offsets[Math.min(offsets.length - 1, segmentIndex + 1)] ?? fromDay + 1; | |
| const span = Math.max(1, toDay - fromDay); | |
| const alpha = offsets.length === 1 ? 0 : (clampedDay - fromDay) / span; | |
| const progress = | |
| offsets.length === 1 ? 0 : (segmentIndex + THREE.MathUtils.clamp(alpha, 0, 1)) / (offsets.length - 1); | |
| const meshIndex = | |
| Math.abs(clampedDay - fromDay) <= Math.abs(toDay - clampedDay) | |
| ? segmentIndex | |
| : Math.min(offsets.length - 1, segmentIndex + 1); | |
| return { | |
| progress, | |
| meshIndex, | |
| meshCount: offsets.length, | |
| day: Math.round(clampedDay), | |
| }; | |
| } | |
| function formatMorphLabel(state) { | |
| if (!state?.meshCount) return ''; | |
| return `Mesh ${state.meshIndex + 1}/${state.meshCount} · Day ${state.day}`; | |
| } | |
| function drawCaptureLabel(context, label, width, height) { | |
| if (!label) return; | |
| context.save(); | |
| context.font = 'bold 16px Inter, Arial, sans-serif'; | |
| const metrics = context.measureText(label); | |
| const boxWidth = Math.min(width - 24, metrics.width + 24); | |
| const boxHeight = 34; | |
| const x = width - boxWidth - 12; | |
| const y = 12; | |
| context.fillStyle = 'rgba(13, 17, 23, 0.78)'; | |
| context.strokeStyle = 'rgba(125, 211, 252, 0.52)'; | |
| context.lineWidth = 1; | |
| context.beginPath(); | |
| context.roundRect(x, y, boxWidth, boxHeight, 8); | |
| context.fill(); | |
| context.stroke(); | |
| context.fillStyle = '#dff6ff'; | |
| context.fillText(label, x + 12, y + 22); | |
| context.restore(); | |
| } | |
| function getSupportedMp4MimeType() { | |
| const mimeTypes = [ | |
| 'video/mp4;codecs="avc1.42E01E"', | |
| 'video/mp4;codecs="avc1.4D401E"', | |
| 'video/mp4;codecs=h264', | |
| 'video/mp4', | |
| ]; | |
| return mimeTypes.find((mimeType) => window.MediaRecorder?.isTypeSupported(mimeType)) || ''; | |
| } | |
| function extractPositionFrames(objects) { | |
| const meshGroups = objects.map(getMeshes); | |
| const meshCount = meshGroups[0].length; | |
| if (meshCount === 0) { | |
| throw new Error('アニメーション対象のmeshがありません。'); | |
| } | |
| for (const meshes of meshGroups) { | |
| if (meshes.length !== meshCount) { | |
| throw new Error('OBJ間のmesh数が一致しません。'); | |
| } | |
| } | |
| const frames = meshGroups.map((meshes) => | |
| meshes.map((mesh, meshIndex) => { | |
| const source = mesh.geometry.getAttribute('position').array; | |
| const first = meshGroups[0][meshIndex].geometry.getAttribute('position').array; | |
| if (source.length !== first.length) { | |
| throw new Error('OBJ間の頂点数が一致しません。'); | |
| } | |
| return new Float32Array(source); | |
| }), | |
| ); | |
| return { meshRefs: meshGroups[0], frames }; | |
| } | |
| function fitCameraToObject(camera, controls, object, displayMaxDimension) { | |
| const { center, maxDimension } = getPrimaryBounds(object); | |
| const lockedMaxDimension = displayMaxDimension || maxDimension; | |
| const distance = Math.max(10, lockedMaxDimension * 8); | |
| setOrthographicFrame(camera, center, lockedMaxDimension, getCanvasAspect(camera.userData.renderer)); | |
| controls.target.copy(center); | |
| controls.minDistance = Math.max(0.01, lockedMaxDimension * 0.05); | |
| controls.maxDistance = Math.max(20, lockedMaxDimension * 30); | |
| controls.minZoom = 0.25; | |
| controls.maxZoom = 12; | |
| controls.update(); | |
| const projectedBounds = refineOrthographicFit(camera, controls, object); | |
| window.__OBJ_VIEWER_DEBUG__ = { | |
| center: center.toArray(), | |
| maxDimension, | |
| camera: { | |
| left: camera.left, | |
| right: camera.right, | |
| top: camera.top, | |
| bottom: camera.bottom, | |
| position: camera.position.toArray(), | |
| target: controls.target.toArray(), | |
| }, | |
| objectMaxDimension: maxDimension, | |
| displayMaxDimension: lockedMaxDimension, | |
| projectedBounds, | |
| }; | |
| return { center, distance, maxDimension: lockedMaxDimension, objectMaxDimension: maxDimension }; | |
| } | |
| function applyMorphFrame(meshRefs, frames, progress) { | |
| if (!meshRefs.length || frames.length === 0) return; | |
| const lastIndex = frames.length - 1; | |
| const scaled = Math.max(0, Math.min(lastIndex, progress * lastIndex)); | |
| const frameIndex = Math.min(lastIndex - 1, Math.floor(scaled)); | |
| const alpha = scaled - frameIndex; | |
| const fromFrame = frames[frameIndex]; | |
| const toFrame = frames[Math.min(lastIndex, frameIndex + 1)]; | |
| meshRefs.forEach((mesh, meshIndex) => { | |
| const target = mesh.geometry.getAttribute('position'); | |
| const values = target.array; | |
| const from = fromFrame[meshIndex]; | |
| const to = toFrame[meshIndex]; | |
| for (let i = 0; i < values.length; i += 1) { | |
| values[i] = from[i] + (to[i] - from[i]) * alpha; | |
| } | |
| target.needsUpdate = true; | |
| mesh.geometry.computeVertexNormals(); | |
| mesh.geometry.computeBoundingBox(); | |
| mesh.geometry.computeBoundingSphere(); | |
| }); | |
| } | |
| const ObjViewer = forwardRef(function ObjViewer({ objText, fileName }, ref) { | |
| const containerRef = useRef(null); | |
| const objectRef = useRef(null); | |
| const sceneRef = useRef(null); | |
| const rendererRef = useRef(null); | |
| const cameraRef = useRef(null); | |
| const controlsRef = useRef(null); | |
| const viewModeRef = useRef('3d'); | |
| const modelCenterRef = useRef(new THREE.Vector3(0, 0, 0)); | |
| const modelDistanceRef = useRef(5); | |
| const modelSizeRef = useRef(5); | |
| const fixedDisplaySizeRef = useRef(null); | |
| const patientIdRef = useRef(''); | |
| const morphAnimationRef = useRef(0); | |
| const [parseError, setParseError] = useState(''); | |
| const [viewMode, setViewMode] = useState('3d'); | |
| const [viewerStatus, setViewerStatus] = useState('Waiting'); | |
| const [morphLabel, setMorphLabel] = useState(''); | |
| const renderCurrentFrame = () => { | |
| const renderer = rendererRef.current; | |
| const scene = sceneRef.current; | |
| const camera = cameraRef.current; | |
| if (!renderer || !scene || !camera) return; | |
| const renderSize = renderer.getSize(new THREE.Vector2()); | |
| const width = Math.max(1, Math.round(renderSize.x)); | |
| const height = Math.max(1, Math.round(renderSize.y)); | |
| if (viewModeRef.current === 'six') { | |
| renderer.setScissorTest(true); | |
| renderer.clear(); | |
| const cols = 3; | |
| const rows = 2; | |
| const cellW = Math.floor(width / cols); | |
| const cellH = Math.floor(height / rows); | |
| const center = modelCenterRef.current; | |
| const activeObject = objectRef.current; | |
| VIEW_SPECS.forEach((spec, index) => { | |
| const col = index % cols; | |
| const row = Math.floor(index / cols); | |
| const x = col * cellW; | |
| const y = height - (row + 1) * cellH; | |
| const aspect = cellW / cellH; | |
| const distance = modelDistanceRef.current * 1.8; | |
| const viewHeight = Math.max(1, modelSizeRef.current * 1.45); | |
| const viewWidth = viewHeight * aspect; | |
| const viewCamera = new THREE.OrthographicCamera( | |
| -viewWidth / 2, | |
| viewWidth / 2, | |
| viewHeight / 2, | |
| -viewHeight / 2, | |
| 0.001, | |
| distance * 4, | |
| ); | |
| viewCamera.position.copy(center).add(spec.dir.clone().multiplyScalar(distance)); | |
| viewCamera.up.copy(spec.up); | |
| viewCamera.lookAt(center); | |
| viewCamera.updateProjectionMatrix(); | |
| if (activeObject) { | |
| refineOrthographicFit( | |
| viewCamera, | |
| { | |
| target: center.clone(), | |
| update() {}, | |
| }, | |
| activeObject, | |
| ); | |
| } | |
| renderer.setViewport(x, y, cellW, cellH); | |
| renderer.setScissor(x, y, cellW, cellH); | |
| renderer.render(scene, viewCamera); | |
| }); | |
| renderer.setScissorTest(false); | |
| renderer.setViewport(0, 0, width, height); | |
| return; | |
| } | |
| renderer.setScissorTest(false); | |
| renderer.setViewport(0, 0, width, height); | |
| renderer.render(scene, camera); | |
| }; | |
| const setSceneObject = (object) => { | |
| const scene = sceneRef.current; | |
| const camera = cameraRef.current; | |
| const controls = controlsRef.current; | |
| if (!scene || !camera || !controls) return; | |
| if (objectRef.current) { | |
| scene.remove(objectRef.current); | |
| disposeObject(objectRef.current); | |
| } | |
| scene.add(object); | |
| objectRef.current = object; | |
| const patientId = getPatientIdFromFileName(object.name); | |
| if (patientId && patientId !== patientIdRef.current) { | |
| patientIdRef.current = patientId; | |
| fixedDisplaySizeRef.current = null; | |
| } | |
| const objectBounds = getPrimaryBounds(object); | |
| if (!fixedDisplaySizeRef.current) { | |
| fixedDisplaySizeRef.current = objectBounds.maxDimension; | |
| } | |
| const fit = fitCameraToObject(camera, controls, object, fixedDisplaySizeRef.current); | |
| const pixelBounds = refineCanvasFit(rendererRef.current, camera, controls, renderCurrentFrame); | |
| modelCenterRef.current.copy(fit.center); | |
| modelDistanceRef.current = fit.distance; | |
| modelSizeRef.current = fit.maxDimension; | |
| if (window.__OBJ_VIEWER_DEBUG__) { | |
| window.__OBJ_VIEWER_DEBUG__.pixelBounds = pixelBounds; | |
| } | |
| renderCurrentFrame(); | |
| }; | |
| const parseMorphObjects = (frames) => { | |
| if (!frames || frames.length < 2) { | |
| throw new Error('アニメーションには2件以上のoriginalデータが必要です。'); | |
| } | |
| return frames.map((frame) => parseObjText(frame.text, frame.file?.name)); | |
| }; | |
| const playMorph = (frames) => { | |
| try { | |
| setParseError(''); | |
| setMorphLabel(''); | |
| setViewerStatus('Animating'); | |
| window.cancelAnimationFrame(morphAnimationRef.current); | |
| const timeline = prepareMorphTimeline(frames); | |
| const objects = parseMorphObjects(timeline.frames); | |
| const { meshRefs, frames: positionFrames } = extractPositionFrames(objects); | |
| fixedDisplaySizeRef.current = getPrimaryBounds(objects[0]).maxDimension; | |
| patientIdRef.current = getPatientIdFromFileName(objects[0].name); | |
| setSceneObject(objects[0]); | |
| for (let i = 1; i < objects.length; i += 1) { | |
| disposeObject(objects[i]); | |
| } | |
| const start = performance.now(); | |
| const duration = Math.max(1200, Math.min(18000, timeline.lastDay * 35)); | |
| const tick = (now) => { | |
| const elapsedRatio = Math.min(1, (now - start) / duration); | |
| const state = getTimelineState(elapsedRatio * timeline.lastDay, timeline.offsets); | |
| applyMorphFrame(meshRefs, positionFrames, state.progress); | |
| setMorphLabel(formatMorphLabel(state, timeline.lastDay)); | |
| renderCurrentFrame(); | |
| if (elapsedRatio < 1) { | |
| morphAnimationRef.current = window.requestAnimationFrame(tick); | |
| } else { | |
| setMorphLabel(formatMorphLabel(getTimelineState(timeline.lastDay, timeline.offsets), timeline.lastDay)); | |
| setViewerStatus('Loaded'); | |
| } | |
| }; | |
| morphAnimationRef.current = window.requestAnimationFrame(tick); | |
| } catch (error) { | |
| setViewerStatus('Loaded'); | |
| setMorphLabel(''); | |
| setParseError(error.message || 'アニメーション作成に失敗しました。'); | |
| } | |
| }; | |
| const downloadMorphMp4 = async (frames, filename) => { | |
| try { | |
| setParseError(''); | |
| setMorphLabel(''); | |
| setViewerStatus('MP4'); | |
| window.cancelAnimationFrame(morphAnimationRef.current); | |
| const mimeType = getSupportedMp4MimeType(); | |
| if (!mimeType) { | |
| throw new Error('このブラウザはMP4録画に対応していません。Chromeを最新版にしてください。'); | |
| } | |
| const timeline = prepareMorphTimeline(frames); | |
| const objects = parseMorphObjects(timeline.frames); | |
| const { meshRefs, frames: positionFrames } = extractPositionFrames(objects); | |
| fixedDisplaySizeRef.current = getPrimaryBounds(objects[0]).maxDimension; | |
| patientIdRef.current = getPatientIdFromFileName(objects[0].name); | |
| setSceneObject(objects[0]); | |
| for (let i = 1; i < objects.length; i += 1) { | |
| disposeObject(objects[i]); | |
| } | |
| const sourceCanvas = rendererRef.current.domElement; | |
| const captureCanvas = document.createElement('canvas'); | |
| const captureWidth = 640; | |
| const captureImageHeight = 360; | |
| const captureLabelHeight = 52; | |
| const captureHeight = captureImageHeight + captureLabelHeight; | |
| captureCanvas.width = captureWidth; | |
| captureCanvas.height = captureHeight; | |
| const captureContext = captureCanvas.getContext('2d'); | |
| const stream = captureCanvas.captureStream(12); | |
| const chunks = []; | |
| const recorder = new MediaRecorder(stream, { | |
| mimeType, | |
| videoBitsPerSecond: 2_400_000, | |
| }); | |
| const frameCount = timeline.lastDay + 1; | |
| const frameDelay = 1000 / 12; | |
| recorder.ondataavailable = (event) => { | |
| if (event.data?.size) chunks.push(event.data); | |
| }; | |
| const recordingDone = new Promise((resolve, reject) => { | |
| recorder.onstop = resolve; | |
| recorder.onerror = () => reject(new Error('MP4録画に失敗しました。')); | |
| }); | |
| recorder.start(); | |
| try { | |
| for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { | |
| const state = getTimelineState(frameIndex, timeline.offsets); | |
| const label = formatMorphLabel(state, timeline.lastDay); | |
| applyMorphFrame(meshRefs, positionFrames, state.progress); | |
| setMorphLabel(label); | |
| renderCurrentFrame(); | |
| captureContext.fillStyle = '#0d1117'; | |
| captureContext.fillRect(0, 0, captureWidth, captureHeight); | |
| captureContext.drawImage(sourceCanvas, 0, captureLabelHeight, captureWidth, captureImageHeight); | |
| drawCaptureLabel(captureContext, label, captureWidth, captureHeight); | |
| await new Promise((resolve) => window.setTimeout(resolve, frameDelay)); | |
| } | |
| } finally { | |
| if (recorder.state !== 'inactive') { | |
| recorder.stop(); | |
| } | |
| } | |
| await recordingDone; | |
| stream.getTracks().forEach((track) => track.stop()); | |
| const blob = new Blob(chunks, { type: mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = filename; | |
| link.click(); | |
| URL.revokeObjectURL(url); | |
| setViewerStatus('Loaded'); | |
| setMorphLabel(formatMorphLabel(getTimelineState(timeline.lastDay, timeline.offsets), timeline.lastDay)); | |
| } catch (error) { | |
| setViewerStatus('Loaded'); | |
| setMorphLabel(''); | |
| setParseError(error.message || 'MP4作成に失敗しました。'); | |
| } | |
| }; | |
| useImperativeHandle(ref, () => ({ playMorph, downloadMorphMp4 })); | |
| useEffect(() => { | |
| const container = containerRef.current; | |
| if (!container) return undefined; | |
| const getCanvasSize = () => { | |
| const rect = container.getBoundingClientRect(); | |
| return { | |
| width: Math.max(320, Math.round(rect.width || container.clientWidth || 320)), | |
| height: Math.min( | |
| 2400, | |
| Math.max(360, Math.round(rect.height || container.clientHeight || 560)), | |
| ), | |
| }; | |
| }; | |
| const initialSize = getCanvasSize(); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x111827); | |
| sceneRef.current = scene; | |
| const renderer = new THREE.WebGLRenderer({ | |
| antialias: true, | |
| preserveDrawingBuffer: true, | |
| }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setSize(initialSize.width, initialSize.height, false); | |
| renderer.shadowMap.enabled = true; | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| renderer.autoClear = true; | |
| rendererRef.current = renderer; | |
| container.appendChild(renderer.domElement); | |
| const initialAspect = initialSize.width / initialSize.height; | |
| const camera = new THREE.OrthographicCamera( | |
| -initialAspect, | |
| initialAspect, | |
| 1, | |
| -1, | |
| 0.001, | |
| 1000, | |
| ); | |
| camera.position.set(0, 0, 10); | |
| camera.userData.renderer = renderer; | |
| cameraRef.current = camera; | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.08; | |
| controlsRef.current = controls; | |
| const hemiLight = new THREE.HemisphereLight(0xffffff, 0x354052, 2.4); | |
| scene.add(hemiLight); | |
| const keyLight = new THREE.DirectionalLight(0xffffff, 2.2); | |
| keyLight.position.set(5, 8, 6); | |
| keyLight.castShadow = true; | |
| scene.add(keyLight); | |
| const fillLight = new THREE.DirectionalLight(0xffffff, 0.65); | |
| fillLight.position.set(-4, 3, -6); | |
| scene.add(fillLight); | |
| const resizeObserver = new ResizeObserver(() => { | |
| const { width, height } = getCanvasSize(); | |
| renderer.setSize(width, height, false); | |
| if (objectRef.current) { | |
| const { center, maxDimension } = getPrimaryBounds(objectRef.current); | |
| const displayMaxDimension = fixedDisplaySizeRef.current || maxDimension; | |
| setOrthographicFrame(camera, center, displayMaxDimension, width / height); | |
| controls.target.copy(center); | |
| controls.update(); | |
| refineOrthographicFit(camera, controls, objectRef.current); | |
| } else { | |
| setOrthographicFrame(camera, new THREE.Vector3(0, 0, 0), 5, width / height); | |
| } | |
| renderCurrentFrame(); | |
| }); | |
| resizeObserver.observe(container); | |
| let animationFrameId = 0; | |
| const animate = () => { | |
| controls.enabled = viewModeRef.current === '3d'; | |
| controls.update(); | |
| renderCurrentFrame(); | |
| animationFrameId = window.requestAnimationFrame(animate); | |
| }; | |
| animate(); | |
| return () => { | |
| window.cancelAnimationFrame(animationFrameId); | |
| window.cancelAnimationFrame(morphAnimationRef.current); | |
| resizeObserver.disconnect(); | |
| controls.dispose(); | |
| renderer.dispose(); | |
| disposeObject(objectRef.current); | |
| renderer.domElement.remove(); | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| if (!objText) { | |
| setViewerStatus('Waiting'); | |
| setMorphLabel(''); | |
| return; | |
| } | |
| try { | |
| setParseError(''); | |
| setMorphLabel(''); | |
| setViewerStatus('Loaded'); | |
| window.cancelAnimationFrame(morphAnimationRef.current); | |
| setSceneObject(parseObjText(objText, fileName)); | |
| } catch (error) { | |
| setViewerStatus('Error'); | |
| setParseError(error.message || 'OBJ parse failed.'); | |
| } | |
| }, [objText, fileName]); | |
| const handleViewModeChange = (mode) => { | |
| viewModeRef.current = mode; | |
| setViewMode(mode); | |
| renderCurrentFrame(); | |
| }; | |
| return ( | |
| <section className="viewer-shell" aria-label="OBJ preview"> | |
| <div className="viewer-header"> | |
| <div> | |
| <p className="eyebrow">Preview</p> | |
| <h2>{fileName || 'OBJファイルを選択'}</h2> | |
| </div> | |
| <div className="viewer-actions"> | |
| <div className="segmented-control" aria-label="view mode"> | |
| <button | |
| className={viewMode === '3d' ? 'active' : ''} | |
| onClick={() => handleViewModeChange('3d')} | |
| > | |
| 3D | |
| </button> | |
| <button | |
| className={viewMode === 'six' ? 'active' : ''} | |
| onClick={() => handleViewModeChange('six')} | |
| > | |
| 6 Views | |
| </button> | |
| </div> | |
| <span className="viewer-status">{viewerStatus}</span> | |
| </div> | |
| </div> | |
| <div ref={containerRef} className="viewer-canvas"> | |
| {!objText && ( | |
| <div className="empty-state"> | |
| <strong>患者IDと日付を選択してください</strong> | |
| <span>3Dではドラッグで回転、ホイールでズーム</span> | |
| </div> | |
| )} | |
| {viewMode === 'six' && objText && ( | |
| <div className="six-view-labels" aria-hidden="true"> | |
| {VIEW_LABELS.map((label) => ( | |
| <span key={label}>{label}</span> | |
| ))} | |
| </div> | |
| )} | |
| {morphLabel && <div className="morph-frame-label">{morphLabel}</div>} | |
| {parseError && <div className="error-banner">{parseError}</div>} | |
| </div> | |
| </section> | |
| ); | |
| }); | |
| export default ObjViewer; | |