Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
186 changes: 181 additions & 5 deletions apps/web/src/components/WorkplaneViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -273,6 +286,9 @@ type ThreeState = {
lastOverlaySync: number;
lastViewCubeSync: number;
rotationHandleSides: RotationHandleSides | null;
rotationRingSet: RotationRingSet;
rotationRingsLastSync: number;
rotationRingsAnimating: boolean;
disposeInteractionListeners: () => void;
resize: () => void;
};
Expand Down Expand Up @@ -2262,6 +2278,22 @@ export function WorkplaneViewport({
const [moveDimensionOverlay, setMoveDimensionOverlay] = useState<MoveDimensionOverlayState | null>(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<RotationRingAxis | null>(null);

useEffect(() => {
setChallengeTutorialCollapsed(false);
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -4357,14 +4432,20 @@ 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(() => {
if (workplaneModeRef.current) {
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(
Expand Down Expand Up @@ -4882,6 +4963,7 @@ export function WorkplaneViewport({
showRotationWheel={activeRotationWheel}
hideSelectionChrome={activeTransformKind === "rotate"}
hideDimensionMarks={false}
hideCornerRotateHandles={rotationMode === "rings"}
rotationWheelAxis={rotationWheelAxis}
pinnedRotationWheelView={pinnedRotationWheelView}
onBeginCameraDrag={beginCameraDragFromOverlay}
Expand Down Expand Up @@ -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)}
/>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -5100,6 +5196,9 @@ function createThreeScene(host: HTMLDivElement): ThreeState {
lastOverlaySync: 0,
lastViewCubeSync: 0,
rotationHandleSides: null,
rotationRingSet,
rotationRingsLastSync: 0,
rotationRingsAnimating: false,
disposeInteractionListeners: () => {},
resize,
};
Expand Down Expand Up @@ -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[],
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/workplane/TransformOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function TransformOverlay({
showRotationWheel,
hideSelectionChrome,
hideDimensionMarks,
hideCornerRotateHandles,
rotationWheelAxis,
pinnedRotationWheelView,
onBeginCameraDrag,
Expand Down Expand Up @@ -236,7 +237,7 @@ export function TransformOverlay({
}}
/>
))}
{box.rotateHandles.map((handle) => (
{hideCornerRotateHandles ? null : box.rotateHandles.map((handle) => (
<button
key={handle.key}
className={`rotate-handle ${handle.className}`}
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/workplane/WorkspaceSettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,25 @@ export function WorkspaceSettingsModal({
snap,
themePreference,
moveDimensionsEnabled,
preferClassicRotateHandles,
onWorkspaceChange,
onSnapChange,
onThemePreferenceChange,
onMoveDimensionsEnabledChange,
onPreferClassicRotateHandlesChange,
onMakeDefault,
onClose,
}: {
workspace: WorkspaceSettings;
snap: GridSize;
themePreference: AppThemePreference;
moveDimensionsEnabled: boolean;
preferClassicRotateHandles: boolean;
onWorkspaceChange: (next: WorkspaceSettings) => void;
onSnapChange: (next: GridSize) => void;
onThemePreferenceChange?: (preference: AppThemePreference) => void;
onMoveDimensionsEnabledChange: (enabled: boolean) => void;
onPreferClassicRotateHandlesChange: (preferClassic: boolean) => void;
onMakeDefault: () => void;
onClose: () => void;
}) {
Expand Down Expand Up @@ -202,6 +206,11 @@ export function WorkspaceSettingsModal({
checked={moveDimensionsEnabled}
onChange={onMoveDimensionsEnabledChange}
/>
<WorkspaceToggle
label="Use classic rotate handles"
checked={preferClassicRotateHandles}
onChange={onPreferClassicRotateHandlesChange}
/>
<WorkspaceToggle label="Show shadows" checked={workspace.showShadows} onChange={(showShadows) => patchWorkspace({ showShadows })} />
<WorkspaceToggle
label="Cruise when adding new shapes"
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/workplane/transformOverlayTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export type TransformOverlayProps = {
showRotationWheel: boolean;
hideSelectionChrome: boolean;
hideDimensionMarks: boolean;
hideCornerRotateHandles: boolean;
rotationWheelAxis: RotationAxis;
pinnedRotationWheelView: PinnedRotationWheelView | null;
onBeginCameraDrag: (event: ReactPointerEvent<Element>) => void;
Expand Down
Loading