"use client" import { Canvas, useFrame, useThree } from '@react-three/fiber' import { Sphere, Line, Text } from '@react-three/drei' import { Leva, useControls, button } from 'leva' import { StrictMode, useRef, useState, useCallback, useEffect } from 'react' import * as THREE from 'three' // Utility function to calculate distance between two points const calculateDistance = (p1, p2) => { return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)) } export default function App() { return ( <StrictMode> <Leva flat /> <Canvas orthographic camera={{ zoom: 50, near: 0.1, far: 200, position: [0, 0, 15] }}> <Experience /> </Canvas> </StrictMode> ) } export function Experience() { const [drawnObjects, setDrawnObjects] = useState([]) const [currentTool, setCurrentTool] = useState('line') const [isDrawing, setIsDrawing] = useState(false) const [drawingState, setDrawingState] = useState(null) const [cursorPosition, setCursorPosition] = useState(null) const { tool, snapEnabled, snapValue, showLengths } = useControls('Drawing Tools', { tool: { value: 'line', options: { Line: 'line', Shape: 'shape', Rectangle: 'rectangle', Delete: 'delete' }, onChange: (value) => { setCurrentTool(value) setIsDrawing(false) setDrawingState(null) } }, snapEnabled: true, snapValue: { value: 0.5, min: 0.1, max: 2, step: 0.1 }, showLengths: false, clear: button(() => { setDrawnObjects([]) setIsDrawing(false) setDrawingState(null) }) }) const snapToGrid = useCallback((point) => { if (!snapEnabled) return point return { x: Math.round(point.x / snapValue) * snapValue, y: Math.round(point.y / snapValue) * snapValue, z: 0 } }, [snapEnabled, snapValue]) const handleObjectDelete = useCallback((objectId) => { setDrawnObjects(prev => prev.filter(obj => obj.id !== objectId)) }, []) const handleCanvasClick = useCallback((point) => { if (currentTool === 'delete') return // Don't handle canvas clicks in delete mode const snappedPoint = snapToGrid(point) if (currentTool === 'line') { if (!isDrawing) { setIsDrawing(true) setDrawingState({ start: snappedPoint, end: snappedPoint }) } else { setDrawnObjects(prev => [...prev, { type: 'line', id: Date.now(), start: drawingState.start, end: snappedPoint }]) setIsDrawing(false) setDrawingState(null) } } else if (currentTool === 'shape') { if (!isDrawing) { setIsDrawing(true) setDrawingState({ points: [snappedPoint] }) } else { const newPoints = [...drawingState.points, snappedPoint] setDrawingState({ points: newPoints }) } } else if (currentTool === 'rectangle') { if (!isDrawing) { setIsDrawing(true) setDrawingState({ corner1: snappedPoint, corner2: snappedPoint }) } else { setDrawnObjects(prev => [...prev, { type: 'rectangle', id: Date.now(), corner1: drawingState.corner1, corner2: snappedPoint }]) setIsDrawing(false) setDrawingState(null) } } }, [currentTool, isDrawing, drawingState, snapToGrid]) const handleMouseMove = useCallback((point) => { const snappedPoint = snapToGrid(point) setCursorPosition(snappedPoint) if (!isDrawing || !drawingState) return if (currentTool === 'line') { setDrawingState(prev => ({ ...prev, end: snappedPoint })) } else if (currentTool === 'rectangle') { setDrawingState(prev => ({ ...prev, corner2: snappedPoint })) } }, [isDrawing, drawingState, currentTool, snapToGrid]) const handleShapeComplete = useCallback(() => { if (currentTool === 'shape' && isDrawing && drawingState?.points.length >= 3) { setDrawnObjects(prev => [...prev, { type: 'shape', id: Date.now(), points: drawingState.points }]) setIsDrawing(false) setDrawingState(null) } }, [currentTool, isDrawing, drawingState]) useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'Escape') { if (currentTool === 'shape') { handleShapeComplete() } else { setIsDrawing(false) setDrawingState(null) } } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [currentTool, handleShapeComplete]) return ( <> <directionalLight position={[1, 2, 3]} intensity={1.5} /> <ambientLight intensity={0.5} /> <DrawingCanvas onCanvasClick={handleCanvasClick} onMouseMove={handleMouseMove} snapEnabled={snapEnabled} snapValue={snapValue} /> {/* Render completed objects */} {drawnObjects.map(obj => ( <DrawnObject key={obj.id} object={obj} isDeleteMode={currentTool === 'delete'} showLengths={showLengths} onDelete={() => handleObjectDelete(obj.id)} /> ))} {/* Render current drawing preview */} {isDrawing && drawingState && ( <DrawingPreview tool={currentTool} state={drawingState} cursorPosition={cursorPosition} showLengths={showLengths} onShapeComplete={handleShapeComplete} /> )} <GridHelper snapValue={snapValue} visible={snapEnabled} /> </> ) } export function DrawingCanvas({ onCanvasClick, onMouseMove, snapEnabled, snapValue }) { const cursorRef = useRef() const planeRef = useRef() return ( <mesh ref={planeRef} position={[0, 0, 0]} onPointerMove={(e) => { const localPoint = planeRef.current.worldToLocal(e.point.clone()) if (snapEnabled) { localPoint.x = Math.round(localPoint.x / snapValue) * snapValue localPoint.y = Math.round(localPoint.y / snapValue) * snapValue } if (cursorRef.current) { cursorRef.current.position.set(localPoint.x, localPoint.y, 0.01) cursorRef.current.visible = true } // Drawing coordinates are directly X,Y with Z=0 const drawingPoint = { x: localPoint.x, y: localPoint.y, z: 0 } onMouseMove(drawingPoint) }} onPointerOut={() => { if (cursorRef.current) { cursorRef.current.visible = false } }} onClick={(e) => { const localPoint = planeRef.current.worldToLocal(e.point.clone()) if (snapEnabled) { localPoint.x = Math.round(localPoint.x / snapValue) * snapValue localPoint.y = Math.round(localPoint.y / snapValue) * snapValue } // Drawing coordinates are directly X,Y with Z=0 const drawingPoint = { x: localPoint.x, y: localPoint.y, z: 0 } onCanvasClick(drawingPoint) }} > <planeGeometry args={[20, 20]} /> <meshBasicMaterial transparent opacity={0.1} color="lightblue" /> <CursorIndicator ref={cursorRef} /> </mesh> ) } export const CursorIndicator = ({ color = "orange", size = 0.1, ...props }) => { const ref = useRef() useFrame(() => { if (ref.current) { ref.current.rotation.x += 0.01 ref.current.rotation.y += 0.01 } }) return ( <mesh ref={ref} raycast={() => null} visible={false} {...props}> <sphereGeometry args={[size]} /> <meshBasicMaterial color={color} /> </mesh> ) } export function DrawnObject({ object, isDeleteMode, showLengths, onDelete }) { const [isHovered, setIsHovered] = useState(false) const commonProps = isDeleteMode ? { onPointerEnter: () => setIsHovered(true), onPointerLeave: () => setIsHovered(false), onClick: (e) => { e.stopPropagation() onDelete() }, style: { cursor: 'pointer' } } : {} const color = isDeleteMode && isHovered ? 'red' : undefined const opacity = isDeleteMode && isHovered ? 0.8 : 1 switch (object.type) { case 'line': return <DrawnLine start={object.start} end={object.end} color={color} opacity={opacity} showLengths={showLengths} {...commonProps} /> case 'shape': return <DrawnShape points={object.points} color={color} opacity={opacity} showLengths={showLengths} {...commonProps} /> case 'rectangle': return <DrawnRectangle corner1={object.corner1} corner2={object.corner2} color={color} opacity={opacity} showLengths={showLengths} {...commonProps} /> default: return null } } export function DrawingPreview({ tool, state, cursorPosition, showLengths, onShapeComplete }) { switch (tool) { case 'line': return state.start && state.end ? ( <DrawnLine start={state.start} end={state.end} color="orange" opacity={0.7} showLengths={showLengths} /> ) : null case 'shape': return ( <> {state.points?.map((point, i) => ( <mesh key={i} position={[point.x, point.y, 0]}> <sphereGeometry args={[0.05]} /> <meshBasicMaterial color="orange" /> </mesh> ))} {state.points?.length > 1 && ( <> <Line points={state.points.map(p => [p.x, p.y, 0])} color="orange" lineWidth={2} /> {/* Show lengths on all drawn segments */} {showLengths && state.points.slice(0, -1).map((point, i) => { const nextPoint = state.points[i + 1] const segmentLength = calculateDistance(point, nextPoint) const midpoint = { x: (point.x + nextPoint.x) / 2, y: (point.y + nextPoint.y) / 2 } return ( <Text key={i} position={[midpoint.x, midpoint.y, 0.1]} fontSize={0.2} color="black" anchorX="center" anchorY="middle" > {segmentLength.toFixed(2)} </Text> ) })} </> )} {/* Preview line from last point to cursor */} {state.points?.length > 0 && cursorPosition && ( <> <Line points={[ [state.points[state.points.length - 1].x, state.points[state.points.length - 1].y, 0], [cursorPosition.x, cursorPosition.y, 0] ]} color="orange" lineWidth={1} transparent opacity={0.5} /> {showLengths && (() => { const lastPoint = state.points[state.points.length - 1] const previewLength = calculateDistance(lastPoint, cursorPosition) const midpoint = { x: (lastPoint.x + cursorPosition.x) / 2, y: (lastPoint.y + cursorPosition.y) / 2 } return ( <Text position={[midpoint.x, midpoint.y, 0.1]} fontSize={0.2} color="orange" anchorX="center" anchorY="middle" > {previewLength.toFixed(2)} </Text> ) })()} </> )} {state.points?.length >= 3 && ( <mesh position={[state.points[0].x, state.points[0].y, 0]} onClick={onShapeComplete} > <sphereGeometry args={[0.1]} /> <meshBasicMaterial color="green" /> </mesh> )} </> ) case 'rectangle': return state.corner1 && state.corner2 ? ( <DrawnRectangle corner1={state.corner1} corner2={state.corner2} color="orange" opacity={0.7} showLengths={showLengths} /> ) : null default: return null } } export function DrawnLine({ start, end, color = "black", opacity = 1, showLengths = false, ...props }) { const points = [[start.x, start.y, 0], [end.x, end.y, 0]] const length = calculateDistance(start, end) const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 } return ( <group {...props}> <Line points={points} color={color} lineWidth={3} /> <mesh position={[start.x, start.y, 0]}> <sphereGeometry args={[0.05]} /> <meshBasicMaterial color={color} transparent opacity={opacity} /> </mesh> <mesh position={[end.x, end.y, 0]}> <sphereGeometry args={[0.05]} /> <meshBasicMaterial color={color} transparent opacity={opacity} /> </mesh> {showLengths && ( <Text position={[midpoint.x, midpoint.y, 0.1]} fontSize={0.2} color="black" anchorX="center" anchorY="middle" > {length.toFixed(2)} </Text> )} </group> ) } export function DrawnShape({ points, color = "crimson", opacity = 1, showLengths = false, ...props }) { if (points.length < 3) return null const shape = new THREE.Shape() shape.moveTo(points[0].x, points[0].y) points.forEach(p => shape.lineTo(p.x, p.y)) shape.lineTo(points[0].x, points[0].y) // Create segments for length display const segments = [] for (let i = 0; i < points.length; i++) { const start = points[i] const end = points[(i + 1) % points.length] // Wrap around to close the shape segments.push({ start, end }) } return ( <group {...props}> <mesh position={[0, 0, 0]}> <shapeGeometry args={[shape]} /> <meshBasicMaterial color={color} transparent opacity={opacity} side={THREE.DoubleSide} /> </mesh> {/* Edge lines for definition */} {segments.map((segment, i) => ( <Line key={`edge-${i}`} points={[[segment.start.x, segment.start.y, 0.01], [segment.end.x, segment.end.y, 0.01]]} color="black" lineWidth={1} /> ))} {showLengths && segments.map((segment, i) => { const length = calculateDistance(segment.start, segment.end) const midpoint = { x: (segment.start.x + segment.end.x) / 2, y: (segment.start.y + segment.end.y) / 2 } return ( <Text key={i} position={[midpoint.x, midpoint.y, 0.1]} fontSize={0.2} color="black" anchorX="center" anchorY="middle" > {length.toFixed(2)} </Text> ) })} </group> ) } export function DrawnRectangle({ corner1, corner2, color = "blue", opacity = 1, showLengths = false, ...props }) { const width = Math.abs(corner2.x - corner1.x) const height = Math.abs(corner2.y - corner1.y) const centerX = (corner1.x + corner2.x) / 2 const centerY = (corner1.y + corner2.y) / 2 // Calculate the four corners const minX = Math.min(corner1.x, corner2.x) const maxX = Math.max(corner1.x, corner2.x) const minY = Math.min(corner1.y, corner2.y) const maxY = Math.max(corner1.y, corner2.y) // Define only two sides to avoid redundant labels const sides = [ { start: { x: minX, y: minY }, end: { x: maxX, y: minY }, length: width }, // bottom { start: { x: maxX, y: minY }, end: { x: maxX, y: maxY }, length: height }, // right ] return ( <group {...props}> <mesh position={[centerX, centerY, 0]}> <planeGeometry args={[width, height]} /> <meshBasicMaterial color={color} transparent opacity={opacity} side={THREE.DoubleSide} /> </mesh> {/* Edge lines for definition - all four sides */} <Line points={[ [minX, minY, 0.01], [maxX, minY, 0.01], // bottom [maxX, maxY, 0.01], [minX, maxY, 0.01], // top [minX, minY, 0.01] // close the rectangle ]} color="black" lineWidth={1} /> {showLengths && sides.map((side, i) => { const midpoint = { x: (side.start.x + side.end.x) / 2, y: (side.start.y + side.end.y) / 2 } return ( <Text key={i} position={[midpoint.x, midpoint.y, 0.1]} fontSize={0.2} color="black" anchorX="center" anchorY="middle" > {side.length.toFixed(2)} </Text> ) })} </group> ) } export function GridHelper({ snapValue, visible }) { if (!visible) return null const lines = [] const size = 20 const divisions = size / snapValue for (let i = -divisions; i <= divisions; i++) { const pos = i * snapValue lines.push( <Line key={`h${i}`} points={[[-size/2, pos, 0], [size/2, pos, 0]]} color="gray" lineWidth={0.5} />, <Line key={`v${i}`} points={[[pos, -size/2, 0], [pos, size/2, 0]]} color="gray" lineWidth={0.5} /> ) } return <group>{lines}</group> }
Comments (0)
Loading comments...