obj-drive-viewer / src /ObjViewer.jsx
hrkjt's picture
Add MP4 label padding
667dbee verified
Raw
History Blame Contribute Delete
32.7 kB
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;