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(' ({ ...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 (

Preview

{fileName || 'OBJファイルを選択'}

{viewerStatus}
{!objText && (
患者IDと日付を選択してください 3Dではドラッグで回転、ホイールでズーム
)} {viewMode === 'six' && objText && ( )} {morphLabel &&
{morphLabel}
} {parseError &&
{parseError}
}
); }); export default ObjViewer;