@@ -9,12 +9,33 @@ import FigureSettings from '@/app/components/FigureSettings';
99import { Pivot } from '@/app/types/figure' ;
1010
1111export default function Stage ( ) {
12- const { project, currentFrameIndex, isPlaying, setCurrentFrameIndex } = useStore ( ) ;
12+ const {
13+ project,
14+ currentFrameIndex,
15+ isPlaying,
16+ setCurrentFrameIndex,
17+ editorMode,
18+ builderFigure,
19+ builderTool,
20+ selectedPivotIds,
21+ addBuilderPivot,
22+ togglePivotSelection,
23+ setBuilderPivotType,
24+ setBuilderRootPivot,
25+ moveBuilderPivot,
26+ connectingPivots,
27+ addConnectingPivot,
28+ clearConnectingPivots,
29+ createLineFromConnecting
30+ } = useStore ( ) ;
1331 const [ interpolatedFrame , setInterpolatedFrame ] = useState ( project . frames [ currentFrameIndex ] ) ;
1432 const svgRef = useRef < SVGSVGElement > ( null ) ;
1533 const wasmRef = useRef < any > ( null ) ;
1634
1735 const { handleMouseDown, handleMouseMove, handleMouseUp, draggingPivotId, pickerState, setPickerState } = useInteraction ( svgRef ) ;
36+ // Builder mode drag state (separate from animation drag)
37+ const [ builderDraggingPivotId , setBuilderDraggingPivotId ] = useState < string | null > ( null ) ;
38+ const [ builderDragOffset , setBuilderDragOffset ] = useState ( { x : 0 , y : 0 } ) ;
1839 const { renderFigure } = useFigureRender ( ) ;
1940 const { isOverDeleteZone, handleMouseMove : handleDeleteMouseMove , handleMouseUp : handleDeleteMouseUp } = useDragToDelete ( ) ;
2041
@@ -114,13 +135,146 @@ export default function Stage() {
114135
115136 const frameToShow = isPlaying ? interpolatedFrame : project . frames [ currentFrameIndex ] ;
116137
138+ // Builder mode click handler
139+ const handleBuilderClick = ( e : React . MouseEvent < SVGSVGElement > ) => {
140+ if ( editorMode !== 'figure' || ! svgRef . current ) return ;
141+
142+ const rect = svgRef . current . getBoundingClientRect ( ) ;
143+ const svgX = ( ( e . clientX - rect . left ) / rect . width ) * 1280 ;
144+ const svgY = ( ( e . clientY - rect . top ) / rect . height ) * 720 ;
145+
146+ if ( builderTool === 'add-pivot' ) {
147+ addBuilderPivot ( svgX , svgY ) ;
148+ }
149+ } ;
150+
151+ // Render builder pivots
152+ const renderBuilderPivots = ( ) => {
153+ if ( ! builderFigure ) return null ;
154+
155+ const allPivots : Array < { pivot : Pivot ; parent : Pivot | null } > = [ ] ;
156+ const collectPivots = ( pivot : Pivot , parent : Pivot | null = null ) => {
157+ allPivots . push ( { pivot, parent } ) ;
158+ pivot . children ?. forEach ( ( child ) => collectPivots ( child , pivot ) ) ;
159+ } ;
160+ // root_pivot is a container; collect its children as roots
161+ builderFigure . root_pivot . children . forEach ( ( child ) => collectPivots ( child , builderFigure . root_pivot ) ) ;
162+
163+ return (
164+ < g >
165+ { /* Render shapes first */ }
166+ { builderFigure . shapes . map ( ( shape , idx ) => {
167+ if ( shape . type === 'line' && shape . pivotIds . length >= 2 ) {
168+ const findPivot = ( id : string ) : Pivot | undefined => {
169+ let found : Pivot | undefined ;
170+ const search = ( p : Pivot ) => {
171+ if ( p . id === id ) { found = p ; return ; }
172+ p . children ?. forEach ( search ) ;
173+ } ;
174+ search ( builderFigure . root_pivot ) ;
175+ return found ;
176+ } ;
177+
178+ const p1 = findPivot ( shape . pivotIds [ 0 ] ) ;
179+ const p2 = findPivot ( shape . pivotIds [ 1 ] ) ;
180+
181+ if ( p1 && p2 ) {
182+ return (
183+ < line
184+ key = { `shape-${ idx } ` }
185+ x1 = { p1 . x }
186+ y1 = { p1 . y }
187+ x2 = { p2 . x }
188+ y2 = { p2 . y }
189+ stroke = { shape . color || builderFigure . color || '#000' }
190+ strokeWidth = { builderFigure . thickness || 4 }
191+ strokeLinecap = "round"
192+ />
193+ ) ;
194+ }
195+ }
196+ return null ;
197+ } ) }
198+
199+ { /* Render pivots last for proper click handling */ }
200+ { allPivots . map ( ( { pivot, parent } ) => {
201+ const isRoot = parent ?. id === builderFigure . root_pivot . id ;
202+ const isSelected = selectedPivotIds . includes ( pivot . id ) ;
203+
204+ let fillColor = '#666' ;
205+ if ( isRoot ) fillColor = '#3b82f6' ; // Blue for root
206+ else if ( pivot . type === 'joint' ) fillColor = '#f97316' ; // Orange for joint
207+ else if ( pivot . type === 'fixed' ) fillColor = '#6b7280' ; // Gray for fixed
208+
209+ if ( isSelected ) fillColor = '#8b5cf6' ; // Purple for selected
210+
211+ return (
212+ < circle
213+ key = { pivot . id }
214+ cx = { pivot . x }
215+ cy = { pivot . y }
216+ r = { isRoot ? 6 : 4 }
217+ fill = { fillColor }
218+ stroke = "white"
219+ strokeWidth = "1.5"
220+ style = { { cursor : 'pointer' } }
221+ onMouseDown = { ( e ) => {
222+ e . stopPropagation ( ) ;
223+ if ( builderTool === 'select' ) {
224+ if ( svgRef . current ) {
225+ const CTM = svgRef . current . getScreenCTM ( ) ;
226+ if ( CTM ) {
227+ const mouseX = ( e . clientX - CTM . e ) / CTM . a ;
228+ const mouseY = ( e . clientY - CTM . f ) / CTM . d ;
229+ setBuilderDragOffset ( { x : mouseX - pivot . x , y : mouseY - pivot . y } ) ;
230+ setBuilderDraggingPivotId ( pivot . id ) ;
231+ }
232+ }
233+ }
234+ } }
235+ onClick = { ( e ) => {
236+ e . stopPropagation ( ) ;
237+ if ( builderTool === 'select' ) {
238+ togglePivotSelection ( pivot . id ) ;
239+ } else if ( builderTool === 'connect' ) {
240+ // Add to connecting sequence
241+ if ( ! connectingPivots . includes ( pivot . id ) ) {
242+ addConnectingPivot ( pivot . id ) ;
243+ // Auto-create line if 2 pivots selected
244+ if ( connectingPivots . length === 1 ) {
245+ createLineFromConnecting ( ) ;
246+ }
247+ }
248+ } else if ( builderTool === 'set-root' ) {
249+ setBuilderRootPivot ( pivot . id ) ;
250+ } else if ( builderTool === 'set-joint' ) {
251+ setBuilderPivotType ( pivot . id , 'joint' ) ;
252+ } else if ( builderTool === 'set-fixed' ) {
253+ setBuilderPivotType ( pivot . id , 'fixed' ) ;
254+ }
255+ } }
256+ />
257+ ) ;
258+ } ) }
259+ </ g >
260+ ) ;
261+ } ;
262+
117263 return (
118264 < >
119265 < svg
120266 ref = { svgRef }
121- viewBox = "0 0 1280 720"
267+ viewBox = "0 0 1280 720"
268+ onClick = { editorMode === 'figure' ? handleBuilderClick : undefined }
122269 onMouseMove = { ( e ) => {
123- if ( svgRef . current && draggingPivotId ) {
270+ if ( editorMode === 'figure' && builderDraggingPivotId && svgRef . current ) {
271+ const CTM = svgRef . current . getScreenCTM ( ) ;
272+ if ( CTM ) {
273+ const mouseX = ( e . clientX - CTM . e ) / CTM . a ;
274+ const mouseY = ( e . clientY - CTM . f ) / CTM . d ;
275+ moveBuilderPivot ( builderDraggingPivotId , mouseX - builderDragOffset . x , mouseY - builderDragOffset . y ) ;
276+ }
277+ } else if ( svgRef . current && draggingPivotId ) {
124278 const rect = svgRef . current . getBoundingClientRect ( ) ;
125279 const svgX = ( ( e . clientX - rect . left ) / rect . width ) * 1280 ;
126280 const svgY = ( ( e . clientY - rect . top ) / rect . height ) * 720 ;
@@ -131,7 +285,9 @@ export default function Stage() {
131285 }
132286 } }
133287 onMouseUp = { ( e ) => {
134- if ( svgRef . current && draggingPivotId ) {
288+ if ( editorMode === 'figure' && builderDraggingPivotId ) {
289+ setBuilderDraggingPivotId ( null ) ;
290+ } else if ( svgRef . current && draggingPivotId ) {
135291 const rect = svgRef . current . getBoundingClientRect ( ) ;
136292 const svgX = ( ( e . clientX - rect . left ) / rect . width ) * 1280 ;
137293 const svgY = ( ( e . clientY - rect . top ) / rect . height ) * 720 ;
@@ -153,34 +309,44 @@ export default function Stage() {
153309 }
154310 } }
155311 onMouseLeave = { ( ) => {
312+ setBuilderDraggingPivotId ( null ) ;
156313 handleMouseUp ( ) ;
157314 handleDeleteMouseUp ( 0 , 0 , null ) ;
158315 } }
159316 className = "bg-surface shadow-sm max-h-[calc(100vh-2rem)] mx-auto"
160317 >
161- { /* Delete Zone - Trash Icon */ }
162- { /* Delete Zone - Trash Icon (Material) */ }
163- < g opacity = { isOverDeleteZone ? 1 : 0.5 } style = { { transition : 'opacity 0.2s' , filter : isOverDeleteZone ? 'drop-shadow(0 0 8px #ef4444)' : 'none' , cursor : 'pointer' } } >
164- < svg x = "1220" y = "660" width = "36" height = "36" viewBox = "0 0 24 24" fill = "#ef4444" >
165- < path d = "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
166- </ svg >
167- </ g >
168-
169- { /* Onion Skinning: Render previous frame if exists and not playing */ }
170- { ! isPlaying && currentFrameIndex > 0 && project . frames [ currentFrameIndex - 1 ] && (
171- < g opacity = "0.3" style = { { filter : 'grayscale(100%)' } } >
172- { project . frames [ currentFrameIndex - 1 ] . figures . map ( figure =>
173- renderFigure ( figure , null , ( ) => { } ) // Non-interactive
174- ) }
318+ { editorMode === 'figure' ? (
319+ // Builder Mode Rendering
320+ < >
321+ { renderBuilderPivots ( ) }
322+ </ >
323+ ) : (
324+ // Animation Mode Rendering
325+ < >
326+ { /* Delete Zone - Trash Icon */ }
327+ < g opacity = { isOverDeleteZone ? 1 : 0.5 } style = { { transition : 'opacity 0.2s' , filter : isOverDeleteZone ? 'drop-shadow(0 0 8px #ef4444)' : 'none' , cursor : 'pointer' } } >
328+ < svg x = "1220" y = "660" width = "36" height = "36" viewBox = "0 0 24 24" fill = "#ef4444" >
329+ < path d = "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
330+ </ svg >
175331 </ g >
176- ) }
177332
178- { /* Current Frame */ }
179- { frameToShow ?. figures . map ( ( figure ) => (
180- < g key = { figure . id } >
181- { renderFigure ( figure , draggingPivotId , handleMouseDown ) }
182- </ g >
183- ) ) }
333+ { /* Onion Skinning: Render previous frame if exists and not playing */ }
334+ { ! isPlaying && currentFrameIndex > 0 && project . frames [ currentFrameIndex - 1 ] && (
335+ < g opacity = "0.3" style = { { filter : 'grayscale(100%)' } } >
336+ { project . frames [ currentFrameIndex - 1 ] . figures . map ( figure =>
337+ renderFigure ( figure , null , ( ) => { } ) // Non-interactive
338+ ) }
339+ </ g >
340+ ) }
341+
342+ { /* Current Frame */ }
343+ { frameToShow ?. figures . map ( ( figure ) => (
344+ < g key = { figure . id } >
345+ { renderFigure ( figure , draggingPivotId , handleMouseDown ) }
346+ </ g >
347+ ) ) }
348+ </ >
349+ ) }
184350 </ svg >
185351 { pickerState . isOpen && pickerState . figureId && (
186352 < FigureSettings
0 commit comments