From 072d7920173e9fc6db083cdde42acbb3aec62e00 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=27=C3=A9lectron=20rare?=
<108685187+electron-rare@users.noreply.github.com>
Date: Tue, 28 Apr 2026 15:14:57 +0200
Subject: [PATCH] fix(3d): media planes in 4:3 with cover-fit crop
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PcbVideoMesh and PcbCarouselMesh now render in 4:3
landscape (1.9 x 1.425) instead of portrait (1.9 x
4.275). Add applyCoverFit() helper that computes
texture.repeat and texture.offset to crop centered
when source aspect differs from target — equivalent
to CSS object-fit: cover. Hooked on loadedmetadata
for videos and on TextureLoader callback for photos.
---
src/components/BmuViewer.tsx | 38 ++++++++++++++++++++++++++++++------
1 file changed, 32 insertions(+), 6 deletions(-)
diff --git a/src/components/BmuViewer.tsx b/src/components/BmuViewer.tsx
index 7a38ba2..f992b84 100644
--- a/src/components/BmuViewer.tsx
+++ b/src/components/BmuViewer.tsx
@@ -248,6 +248,26 @@ function CardModel({ file }: { file: string }) {
return ;
}
+/* ── Cover-fit helper: crop centered to target aspect (like CSS object-fit:cover) ── */
+function applyCoverFit(texture: THREE.Texture, targetAspect: number) {
+ const img = texture.image as (HTMLImageElement | HTMLVideoElement | { width: number; height: number; videoWidth?: number; videoHeight?: number } | undefined);
+ if (!img) return;
+ const w = (img as HTMLVideoElement).videoWidth || (img as HTMLImageElement).width;
+ const h = (img as HTMLVideoElement).videoHeight || (img as HTMLImageElement).height;
+ if (!w || !h) return;
+ const sourceAspect = w / h;
+ if (sourceAspect > targetAspect) {
+ texture.repeat.set(targetAspect / sourceAspect, 1);
+ texture.offset.set((1 - targetAspect / sourceAspect) / 2, 0);
+ } else {
+ texture.repeat.set(1, sourceAspect / targetAspect);
+ texture.offset.set(0, (1 - sourceAspect / targetAspect) / 2);
+ }
+ texture.needsUpdate = true;
+}
+
+const MEDIA_ASPECT = 4 / 3;
+
/* ── Video plane on PCB bottom (real 3D mesh with VideoTexture) ── */
function PcbVideoMesh({ offset, src, visible }: {
offset: [number, number, number]; src: string; visible: boolean;
@@ -256,9 +276,8 @@ function PcbVideoMesh({ offset, src, visible }: {
const videoRef = useRef(null);
const texRef = useRef(null);
- // Mosfet card local space: ~0.38 x 0.86 (XZ), Y = thickness
- // Plane size: cover most of the bottom face
- const planeW = 1.9, planeH = 4.275;
+ // Plane size in 4:3 landscape; covers center of the mosfet bottom face
+ const planeW = 1.9, planeH = planeW / MEDIA_ASPECT;
useEffect(() => {
const video = document.createElement('video');
@@ -276,7 +295,10 @@ function PcbVideoMesh({ offset, src, visible }: {
tex.colorSpace = THREE.SRGBColorSpace;
texRef.current = tex;
- return () => { video.pause(); video.src = ''; tex.dispose(); };
+ const onMeta = () => applyCoverFit(tex, MEDIA_ASPECT);
+ video.addEventListener('loadedmetadata', onMeta);
+
+ return () => { video.removeEventListener('loadedmetadata', onMeta); video.pause(); video.src = ''; tex.dispose(); };
}, [src]);
useEffect(() => {
@@ -331,12 +353,16 @@ function PcbCarouselMesh({ offset, visible }: {
const textures = useRef([]);
const [idx, setIdx] = useState(0);
const autoTimer = useRef(0);
- const planeW = 1.9, planeH = 4.275;
+ const planeW = 1.9, planeH = planeW / MEDIA_ASPECT;
useEffect(() => {
const loader = new THREE.TextureLoader();
textures.current = CAROUSEL_PHOTOS.map(url => {
- const t = loader.load(url);
+ const t = loader.load(url, (loaded) => {
+ loaded.colorSpace = THREE.SRGBColorSpace;
+ loaded.minFilter = THREE.LinearFilter;
+ applyCoverFit(loaded, MEDIA_ASPECT);
+ });
t.colorSpace = THREE.SRGBColorSpace;
t.minFilter = THREE.LinearFilter;
return t;