diff --git a/apps/web/src/components/WorkplaneViewport.tsx b/apps/web/src/components/WorkplaneViewport.tsx index 8226e0b..400d5b0 100644 --- a/apps/web/src/components/WorkplaneViewport.tsx +++ b/apps/web/src/components/WorkplaneViewport.tsx @@ -64,6 +64,19 @@ import { } from "@/components/workplane/TransformOverlay"; import type { AlignAxis, AlignHandleStatus, AlignTarget, GridSize, MeasurementAccuracy, ShapeAsset, WorkplaneShape, WorkplaneWorkspaceSettings } from "@/types/sketchforge"; import type { CadModifierEdge } from "@/lib/cadModifierTypes"; +import { + createRotationRingSet, + ROTATION_RING_AXES, + targetRingOpacities, + updateRotationRingSet, + type RotationRingAxis, + type RotationRingSet, +} from "@/lib/rotationRings"; +import { + readPreferClassicRotateHandles, + setPreferClassicRotateHandles, + useRotationMode, +} from "@/lib/useRotationMode"; const WORKPLANE_WIDTH = 200; const WORKPLANE_DEPTH = 140; @@ -273,6 +286,9 @@ type ThreeState = { lastOverlaySync: number; lastViewCubeSync: number; rotationHandleSides: RotationHandleSides | null; + rotationRingSet: RotationRingSet; + rotationRingsLastSync: number; + rotationRingsAnimating: boolean; disposeInteractionListeners: () => void; resize: () => void; }; @@ -2262,6 +2278,22 @@ export function WorkplaneViewport({ const [moveDimensionOverlay, setMoveDimensionOverlay] = useState(null); const [moveDimensionsEnabled, setMoveDimensionsEnabled] = useState(true); const [challengeTutorialCollapsed, setChallengeTutorialCollapsed] = useState(false); + const rotationMode = useRotationMode(); + const rotationModeRef = useRef(rotationMode); + useEffect(() => { + rotationModeRef.current = rotationMode; + if (threeRef.current) { + threeRef.current.needsRender = true; + } + }, [rotationMode]); + const [preferClassicRotateHandles, setPreferClassicRotateHandlesState] = useState(() => + readPreferClassicRotateHandles(), + ); + const changePreferClassicRotateHandles = useCallback((preferClassic: boolean) => { + setPreferClassicRotateHandlesState(preferClassic); + setPreferClassicRotateHandles(preferClassic); + }, []); + const hoveredRingAxisRef = useRef(null); useEffect(() => { setChallengeTutorialCollapsed(false); @@ -2779,7 +2811,7 @@ export function WorkplaneViewport({ const now = performance.now(); const controlsChanged = state.controls.update(); const cameraSettled = state.wasCameraMoving && !controlsChanged; - if (!controlsChanged && !state.needsRender && !cameraSettled) { + if (!controlsChanged && !state.needsRender && !cameraSettled && !state.rotationRingsAnimating) { return; } constrainCamera(state, workspaceRef.current); @@ -2792,7 +2824,7 @@ export function WorkplaneViewport({ syncViewCube(state, viewCubeRef.current); state.lastViewCubeSync = now; } - if (controlsChanged || cameraSettled || state.needsRender || now - state.lastOverlaySync > 96) { + if (controlsChanged || cameraSettled || state.needsRender || state.rotationRingsAnimating || now - state.lastOverlaySync > 96) { const previewShapes = previewShapesForDrag(shapesRef.current, dragRef.current); syncTransformOverlay( state, @@ -2805,6 +2837,21 @@ export function WorkplaneViewport({ false, placementWorkplaneRef.current, ); + const activeTransform = transformRef.current; + const activeRingAxis: RotationRingAxis | null = + activeTransform && activeTransform.kind === "rotate" && activeTransform.handleKey.startsWith("rotate-ring-") + ? (activeTransform.rotationAxis as RotationRingAxis) + : null; + syncRotationRings( + state, + previewShapes, + renderSelectionIds(), + rotationModeRef.current === "rings" && !workplaneModeRef.current, + controlsChanged, + hoveredRingAxisRef.current, + activeRingAxis, + placementWorkplaneRef.current, + ); syncAlignOverlay(state, alignReferenceShapesRef.current, selectedIdsRef.current, alignModeRef.current, alignAnchorIdRef.current, alignHandlesRef.current, alignOverlayRef, setAlignOverlay); syncMirrorOverlay(state, mirrorReferenceShapesRef.current, selectedIdsRef.current, mirrorModeRef.current, mirrorOverlayRef, setMirrorOverlay); syncRulerOverlay(state, rulerModelRef.current, rulerOverlayRef, setRulerOverlay, workspaceRef.current.accuracy); @@ -2849,6 +2896,7 @@ export function WorkplaneViewport({ } disposeChildren(state.shapeLayer); state.shapeRecords.clear(); + state.rotationRingSet.dispose(); disposeChildren(state.helperLayer); disposeChildren(state.moveDimensionLayer); disposeChildren(state.modifierLayer); @@ -3899,7 +3947,7 @@ export function WorkplaneViewport({ const pickTransformHandle = useCallback((clientX: number, clientY: number) => { const state = threeRef.current; - if (!state || selectedIdsRef.current.length !== 1) { + if (!state || selectedIdsRef.current.length === 0) { return null; } @@ -3915,8 +3963,16 @@ export function WorkplaneViewport({ return null; } + // Rotation rings work for any selection size (single or multi). Every other + // transform-handle mesh requires a single-shape selection because its + // hit-test object is bound to a specific shape id. + const isRotationRing = typeof hit.object.userData.rotationRingAxis === "string"; + if (!isRotationRing && selectedIdsRef.current.length !== 1) { + return null; + } + return { - id: hit.object.userData.shapeId as string, + id: (hit.object.userData.shapeId as string | undefined) ?? selectedIdsRef.current[0], kind: hit.object.userData.transformHandle as TransformHandleKind, handleKey: (hit.object.userData.transformHandleKey as string | undefined) ?? (hit.object.userData.transformHandle as string), planeY: typeof hit.object.userData.transformPlaneY === "number" ? (hit.object.userData.transformPlaneY as number) : 0, @@ -4290,6 +4346,25 @@ export function WorkplaneViewport({ return; } + if ( + rotationModeRef.current === "rings" && + !dragRef.current && + !marqueeRef.current && + selectedIdsRef.current.length > 0 + ) { + const hit = pickTransformHandle(event.clientX, event.clientY); + const nextHover: RotationRingAxis | null = + hit && hit.kind === "rotate" && hit.handleKey.startsWith("rotate-ring-") + ? (rotationAxisForHandle(hit.handleKey) as RotationRingAxis) + : null; + if (nextHover !== hoveredRingAxisRef.current) { + hoveredRingAxisRef.current = nextHover; + if (threeRef.current) { + threeRef.current.needsRender = true; + } + } + } + const marquee = marqueeRef.current; if (marquee) { const state = threeRef.current; @@ -4357,7 +4432,7 @@ export function WorkplaneViewport({ threeRef.current.needsRender = true; } }, - [pickPlacementSurface, setMarqueeFromState, toPlacementWorkplanePoint, toRawPlanePoint, updateModifierEdgeHover, updateRulerHover, updateTransform], + [pickPlacementSurface, pickTransformHandle, setMarqueeFromState, toPlacementWorkplanePoint, toRawPlanePoint, updateModifierEdgeHover, updateRulerHover, updateTransform], ); const handlePointerLeave = useCallback(() => { @@ -4365,6 +4440,12 @@ export function WorkplaneViewport({ syncWorkplaneHoverPreview(threeRef.current, null, workspaceRef.current, resolvedThemeRef.current); } if (modifierActiveRef.current) clearModifierEdgeHover(); + if (hoveredRingAxisRef.current !== null) { + hoveredRingAxisRef.current = null; + if (threeRef.current) { + threeRef.current.needsRender = true; + } + } }, [clearModifierEdgeHover]); const finishDrag = useCallback( @@ -4882,6 +4963,7 @@ export function WorkplaneViewport({ showRotationWheel={activeRotationWheel} hideSelectionChrome={activeTransformKind === "rotate"} hideDimensionMarks={false} + hideCornerRotateHandles={rotationMode === "rings"} rotationWheelAxis={rotationWheelAxis} pinnedRotationWheelView={pinnedRotationWheelView} onBeginCameraDrag={beginCameraDragFromOverlay} @@ -4951,10 +5033,12 @@ export function WorkplaneViewport({ snap={snap} themePreference={themePreference} moveDimensionsEnabled={moveDimensionsEnabled} + preferClassicRotateHandles={preferClassicRotateHandles} onWorkspaceChange={setWorkspace} onSnapChange={setSnap} onThemePreferenceChange={onThemePreferenceChange} onMoveDimensionsEnabledChange={changeMoveDimensionsEnabled} + onPreferClassicRotateHandlesChange={changePreferClassicRotateHandles} onMakeDefault={makeWorkspaceDefault} onClose={() => setSettingsOpen(false)} /> @@ -5053,6 +5137,18 @@ function createThreeScene(host: HTMLDivElement): ThreeState { const modifierLayer = new THREE.Group(); modifierLayer.name = "EdgeModifier"; modifierLayer.layers.set(RENDER_LAYER_MODIFIERS); + const rotationRingSet = createRotationRingSet(); + rotationRingSet.group.layers.set(RENDER_LAYER_HELPERS); + for (const axis of ROTATION_RING_AXES) { + const mesh = rotationRingSet.rings[axis].mesh; + mesh.layers.set(RENDER_LAYER_HELPERS); + // Tag as a transform handle so pickTransformHandle raycast can find it — + // shapeId is written per selection in syncRotationRings. + mesh.userData.transformHandle = "rotate"; + mesh.userData.transformHandleKey = `rotate-ring-${axis}`; + mesh.userData.rotationRingAxis = axis; + } + helperLayer.add(rotationRingSet.group); scene.add(workplaneLayer, workplanePreviewLayer, shapeLayer, helperLayer, moveDimensionLayer, modifierLayer); const raycaster = new THREE.Raycaster(); @@ -5100,6 +5196,9 @@ function createThreeScene(host: HTMLDivElement): ThreeState { lastOverlaySync: 0, lastViewCubeSync: 0, rotationHandleSides: null, + rotationRingSet, + rotationRingsLastSync: 0, + rotationRingsAnimating: false, disposeInteractionListeners: () => {}, resize, }; @@ -6119,6 +6218,83 @@ function updateTransformOverlayDom(state: ThreeState, next: TransformOverlayStat }); } +function syncRotationRings( + state: ThreeState, + shapes: WorkplaneShape[], + selectedIds: string[], + ringsEnabled: boolean, + cameraMoving: boolean, + hoveredAxis: RotationRingAxis | null, + activeAxis: RotationRingAxis | null, + workplane: PlacementWorkplane = horizontalPlacementWorkplane(), +) { + const now = performance.now(); + const deltaMs = state.rotationRingsLastSync === 0 ? 0 : now - state.rotationRingsLastSync; + state.rotationRingsLastSync = now; + + const setRingsHidden = () => { + if (!state.rotationRingSet.group.visible) return; + state.rotationRingSet.group.visible = false; + for (const axis of ROTATION_RING_AXES) { + state.rotationRingSet.rings[axis].mesh.visible = false; + } + state.needsRender = true; + }; + if (!ringsEnabled || selectedIds.length < 1) { + setRingsHidden(); + state.rotationRingsAnimating = false; + return; + } + const frame = selectionFrameForShapes(shapes, selectedIds, workplane); + if (!frame) { + setRingsHidden(); + state.rotationRingsAnimating = false; + return; + } + + // Bind ring meshes to the first selected shape id so pickTransformHandle's + // synthesized handle points at a real shape. + const primaryId = selectedIds[0]; + for (const axis of ROTATION_RING_AXES) { + state.rotationRingSet.rings[axis].mesh.userData.shapeId = primaryId; + } + + const boundingSphereRadius = 0.5 * Math.hypot(frame.width, frame.height, frame.depth); + const canvas = state.renderer.domElement; + const viewportHeightPx = Math.max(1, canvas.clientHeight); + const wasVisible = state.rotationRingSet.group.visible; + state.rotationRingSet.group.visible = true; + for (const axis of ROTATION_RING_AXES) { + state.rotationRingSet.rings[axis].mesh.visible = true; + } + updateRotationRingSet(state.rotationRingSet, { + frameCenter: frame.center, + frameXAxis: frame.xAxis, + frameYAxis: frame.yAxis, + frameZAxis: frame.zAxis, + boundingSphereRadius, + camera: state.camera, + viewportHeightPx, + activeAxis, + hoveredAxis, + cameraMoving, + deltaMs, + }); + // Track whether opacities are still easing toward their targets so the + // animate loop keeps ticking until convergence — otherwise the ease stalls + // between the 96 ms overlay-sync interval. + const targets = targetRingOpacities({ activeAxis, hoveredAxis, cameraMoving }); + state.rotationRingsAnimating = false; + for (const axis of ROTATION_RING_AXES) { + const opacity = state.rotationRingSet.rings[axis].mesh.material.opacity; + if (Math.abs(opacity - targets[axis]) > 0.005) { + state.rotationRingsAnimating = true; + break; + } + } + if (!wasVisible) state.needsRender = true; +} + function syncTransformOverlay( state: ThreeState, shapes: WorkplaneShape[], diff --git a/apps/web/src/components/workplane/TransformOverlay.tsx b/apps/web/src/components/workplane/TransformOverlay.tsx index bccf294..4123ae0 100644 --- a/apps/web/src/components/workplane/TransformOverlay.tsx +++ b/apps/web/src/components/workplane/TransformOverlay.tsx @@ -30,6 +30,7 @@ export function TransformOverlay({ showRotationWheel, hideSelectionChrome, hideDimensionMarks, + hideCornerRotateHandles, rotationWheelAxis, pinnedRotationWheelView, onBeginCameraDrag, @@ -236,7 +237,7 @@ export function TransformOverlay({ }} /> ))} - {box.rotateHandles.map((handle) => ( + {hideCornerRotateHandles ? null : box.rotateHandles.map((handle) => (