Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 64 additions & 9 deletions src/CadViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,19 @@ const CadViewerInner = (props: any) => {
return stored === "true"
})
const [cameraPreset, setCameraPreset] = useState<CameraPreset>("Custom")
const [shouldUseOrthographicCamera, setShouldUseOrthographicCamera] =
useState(() => {
const stored = window.localStorage.getItem(
"cadViewerUseOrthographicCamera",
)
return stored === "true"
})
const { visibility, toggleLayer } = useLayerVisibility()

const cameraControllerRef = useRef<CameraController | null>(null)
const [cameraControllerReadyVersion, setCameraControllerReadyVersion] =
useState(0)
const lastAppliedControllerVersionRef = useRef(cameraControllerReadyVersion)
const externalCameraControllerReady = props.onCameraControllerReady as
| ((controller: CameraController | null) => void)
| undefined
Expand Down Expand Up @@ -56,6 +66,10 @@ const CadViewerInner = (props: any) => {
setAutoRotateUserToggled(true)
}, [])

const toggleOrthographicCamera = useCallback(() => {
setShouldUseOrthographicCamera((prev) => !prev)
}, [])

const downloadGltf = useGlobalDownloadGltf()

const closeMenu = useCallback(() => {
Expand All @@ -64,25 +78,52 @@ const CadViewerInner = (props: any) => {

const handleCameraControllerReady = useCallback(
(controller: CameraController | null) => {
const wasNull = cameraControllerRef.current === null
cameraControllerRef.current = controller
externalCameraControllerReady?.(controller)
if (controller && cameraPreset !== "Custom") {
controller.animateToPreset(cameraPreset)
if (controller) {
// Always increment version when we get a controller
// This ensures preset re-application works after camera type toggle
setCameraControllerReadyVersion((version) => version + 1)
} else if (wasNull) {
// If we had a controller and now it's null, don't increment
// This prevents unnecessary re-renders
}
},
[cameraPreset, externalCameraControllerReady],
[externalCameraControllerReady],
)

const handleCameraPresetSelect = useCallback(
(preset: CameraPreset) => {
setCameraPreset(preset)
if (preset !== "Custom") {
lastAppliedControllerVersionRef.current = cameraControllerReadyVersion
cameraControllerRef.current?.animateToPreset(preset)
}
closeMenu()
if (preset === "Custom") return
cameraControllerRef.current?.animateToPreset(preset)
},
[closeMenu],
[cameraControllerReadyVersion, closeMenu],
)

useEffect(() => {
if (!cameraControllerRef.current) {
// If controller becomes null (e.g., during camera type toggle), reset tracking
lastAppliedControllerVersionRef.current = -1
return
}
if (cameraPreset === "Custom") return

// Only skip if we've already applied this preset for this controller version
if (
lastAppliedControllerVersionRef.current === cameraControllerReadyVersion
) {
return
}

lastAppliedControllerVersionRef.current = cameraControllerReadyVersion
cameraControllerRef.current.animateToPreset(cameraPreset)
}, [cameraPreset, cameraControllerReadyVersion])

useEffect(() => {
const stored = window.localStorage.getItem("cadViewerEngine")
if (stored === "jscad" || stored === "manifold") {
Expand All @@ -105,9 +146,16 @@ const CadViewerInner = (props: any) => {
)
}, [autoRotateUserToggled])

const viewerKey = props.circuitJson
? JSON.stringify(props.circuitJson)
: undefined
useEffect(() => {
window.localStorage.setItem(
"cadViewerUseOrthographicCamera",
String(shouldUseOrthographicCamera),
)
}, [shouldUseOrthographicCamera])

// Don't use viewerKey - it causes performance issues with JSON.stringify
// and the component already handles circuit changes properly
const viewerKey = undefined

return (
<div
Expand All @@ -131,13 +179,15 @@ const CadViewerInner = (props: any) => {
autoRotateDisabled={props.autoRotateDisabled || !autoRotate}
onUserInteraction={handleUserInteraction}
onCameraControllerReady={handleCameraControllerReady}
shouldUseOrthographicCamera={shouldUseOrthographicCamera}
/>
) : (
<CadViewerManifold
{...props}
autoRotateDisabled={props.autoRotateDisabled || !autoRotate}
onUserInteraction={handleUserInteraction}
onCameraControllerReady={handleCameraControllerReady}
shouldUseOrthographicCamera={shouldUseOrthographicCamera}
/>
)}
<div
Expand All @@ -163,6 +213,7 @@ const CadViewerInner = (props: any) => {
engine={engine}
cameraPreset={cameraPreset}
autoRotate={autoRotate}
shouldUseOrthographicCamera={shouldUseOrthographicCamera}
onEngineSwitch={(newEngine) => {
setEngine(newEngine)
closeMenu()
Expand All @@ -172,6 +223,10 @@ const CadViewerInner = (props: any) => {
toggleAutoRotate()
closeMenu()
}}
onOrthographicToggle={() => {
toggleOrthographicCamera()
closeMenu()
}}
onDownloadGltf={() => {
downloadGltf()
closeMenu()
Expand Down
57 changes: 54 additions & 3 deletions src/CadViewerContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,24 @@ export type {
CameraPreset,
} from "./hooks/useCameraController"

declare global {
interface Window {
TSCI_MAIN_CAMERA_ROTATION: THREE.Euler
TSCI_MAIN_CAMERA_QUATERNION: THREE.Quaternion
}
}

if (typeof window !== "undefined") {
window.TSCI_MAIN_CAMERA_ROTATION ??= new THREE.Euler(0, 0, 0)
window.TSCI_MAIN_CAMERA_QUATERNION ??= new THREE.Quaternion()
}

export const RotationTracker = () => {
const { camera } = useThree()
useFrame(() => {
if (camera) {
window.TSCI_MAIN_CAMERA_ROTATION = camera.rotation
window.TSCI_MAIN_CAMERA_ROTATION.copy(camera.rotation)
window.TSCI_MAIN_CAMERA_QUATERNION.copy(camera.quaternion)
}
})

Expand All @@ -37,6 +50,7 @@ interface Props {
boardCenter?: { x: number; y: number }
onUserInteraction?: () => void
onCameraControllerReady?: (controller: CameraController | null) => void
shouldUseOrthographicCamera?: boolean
}

export const CadViewerContainer = forwardRef<
Expand All @@ -53,6 +67,7 @@ export const CadViewerContainer = forwardRef<
boardCenter,
onUserInteraction,
onCameraControllerReady,
shouldUseOrthographicCamera,
},
ref,
) => {
Expand Down Expand Up @@ -81,12 +96,39 @@ export const CadViewerContainer = forwardRef<
return new THREE.Vector3(0, 0, 0)
}, [orbitTarget])

const cameraModeKey = shouldUseOrthographicCamera
? "orthographic"
: "perspective"

const { cameraAnimatorProps, handleControlsChange } = useCameraController({
key: cameraModeKey,
defaultTarget,
initialCameraPosition,
onCameraControllerReady,
})

const orthographicFrustumSize = useMemo(() => {
if (boardDimensions) {
const width = boardDimensions.width ?? 0
const height = boardDimensions.height ?? 0
const maxDimension = Math.max(width, height)
return Math.max(maxDimension * 1.5, 10)
}
const [x, y, z] = initialCameraPosition
const maxComponent = Math.max(Math.abs(x), Math.abs(y), Math.abs(z))
return Math.max(maxComponent * 2, 10)
}, [initialCameraPosition, boardDimensions])

const mutableInitialCameraPosition = useMemo(
() =>
[
initialCameraPosition[0],
initialCameraPosition[1],
initialCameraPosition[2],
] as [number, number, number],
[initialCameraPosition],
)

return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<div
Expand All @@ -111,12 +153,21 @@ export const CadViewerContainer = forwardRef<
<Canvas
ref={ref}
scene={{ up: new THREE.Vector3(0, 0, 1) }}
camera={{ up: [0, 0, 1], position: initialCameraPosition }}
camera={{
up: [0, 0, 1],
position: mutableInitialCameraPosition,
type: shouldUseOrthographicCamera ? "orthographic" : "perspective",
frustumSize: orthographicFrustumSize,
}}
>
<CameraAnimator {...cameraAnimatorProps} />
<CameraAnimator
key={`camera-animator-${shouldUseOrthographicCamera ? "orthographic" : "perspective"}`}
{...cameraAnimatorProps}
/>
<RotationTracker />
{isInteractionEnabled && (
<OrbitControls
key={shouldUseOrthographicCamera ? "orthographic" : "perspective"}
autoRotate={!autoRotateDisabled}
autoRotateSpeed={1}
onStart={onUserInteraction}
Expand Down
3 changes: 3 additions & 0 deletions src/CadViewerJscad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface Props {
clickToInteractEnabled?: boolean
onUserInteraction?: () => void
onCameraControllerReady?: (controller: CameraController | null) => void
shouldUseOrthographicCamera?: boolean
}

export const CadViewerJscad = forwardRef<
Expand All @@ -45,6 +46,7 @@ export const CadViewerJscad = forwardRef<
clickToInteractEnabled,
onUserInteraction,
onCameraControllerReady,
shouldUseOrthographicCamera,
},
ref,
) => {
Expand Down Expand Up @@ -118,6 +120,7 @@ export const CadViewerJscad = forwardRef<
boardCenter={boardCenter}
onUserInteraction={onUserInteraction}
onCameraControllerReady={onCameraControllerReady}
shouldUseOrthographicCamera={shouldUseOrthographicCamera}
>
{boardStls.map(({ stlData, color, layerType }, index) => (
<VisibleSTLModel
Expand Down
3 changes: 3 additions & 0 deletions src/CadViewerManifold.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ type CadViewerManifoldProps = {
clickToInteractEnabled?: boolean
onUserInteraction?: () => void
onCameraControllerReady?: (controller: CameraController | null) => void
shouldUseOrthographicCamera?: boolean
} & (
| { circuitJson: AnyCircuitElement[]; children?: React.ReactNode }
| { circuitJson?: never; children: React.ReactNode }
Expand All @@ -133,6 +134,7 @@ const CadViewerManifold: React.FC<CadViewerManifoldProps> = ({
onUserInteraction,
children,
onCameraControllerReady,
shouldUseOrthographicCamera,
}) => {
const childrenCircuitJson = useConvertChildrenToCircuitJson(children)
const circuitJson = useMemo(() => {
Expand Down Expand Up @@ -307,6 +309,7 @@ try {
boardCenter={boardCenter}
onUserInteraction={onUserInteraction}
onCameraControllerReady={onCameraControllerReady}
shouldUseOrthographicCamera={shouldUseOrthographicCamera}
>
<BoardMeshes
geometryMeshes={geometryMeshes}
Expand Down
27 changes: 27 additions & 0 deletions src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ interface ContextMenuProps {
engine: "jscad" | "manifold"
cameraPreset: CameraPreset
autoRotate: boolean
shouldUseOrthographicCamera: boolean
onEngineSwitch: (engine: "jscad" | "manifold") => void
onCameraPresetSelect: (preset: CameraPreset) => void
onAutoRotateToggle: () => void
onOrthographicToggle: () => void
onDownloadGltf: () => void
}

Expand Down Expand Up @@ -102,9 +104,11 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
engine,
cameraPreset,
autoRotate,
shouldUseOrthographicCamera,
onEngineSwitch,
onCameraPresetSelect,
onAutoRotateToggle,
onOrthographicToggle,
onDownloadGltf,
}) => {
const [cameraSubOpen, setCameraSubOpen] = useState(false)
Expand Down Expand Up @@ -226,6 +230,29 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
</span>
</DropdownMenu.Item>

<DropdownMenu.Item
style={{
...itemStyles,
...itemPaddingStyles,
backgroundColor:
hoveredItem === "orthographic" ? "#404040" : "transparent",
}}
onSelect={(e) => e.preventDefault()}
onPointerDown={(e) => {
e.preventDefault()
onOrthographicToggle()
}}
onMouseEnter={() => setHoveredItem("orthographic")}
onMouseLeave={() => setHoveredItem(null)}
>
<span style={iconContainerStyles}>
{shouldUseOrthographicCamera && <CheckIcon />}
</span>
<span style={{ display: "flex", alignItems: "center" }}>
Orthographic camera
</span>
</DropdownMenu.Item>

{/* Appearance Menu */}
<AppearanceMenu />

Expand Down
Loading