diff --git a/Makefile b/Makefile index 22de501..989b4d6 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ EveCanvas_FILES = \ Sources/EVEViewController.mm \ Sources/EVEGLView.mm \ Sources/EVEFrameStreamClient.m \ + Sources/EVEDashboardClient.m \ Sources/EVESensorUplinkClient.m \ Sources/EVEH264StreamDecoder.m diff --git a/README.md b/README.md index e8b5920..6c8cbec 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,13 @@ the development machine. - `EVESensorUplinkClient` opens separate WebSocket uplinks for camera and microphone frame-events so sensor traffic does not block display/control traffic. +- `EVEDashboardClient` opens the native Mimir dashboard socket and receives + scene/control state snapshots. - `EVEViewController` captures camera frames with AVFoundation and microphone blocks with AVAudioEngine, then sends `eve-camera` and `eve-mic` samples to - Mimir. + Mimir. It also renders the dashboard natively with UIKit: a scene graph, + draggable source panels, visibility/reset controls, and multitouch + pan/pinch/rotate transform commands. ## Invariants @@ -69,6 +73,28 @@ uses JSON plus base64 payloads because it is inspectable and already matches Mimir's frame-event source. Replace it with binary framing only after the sample contract is proven on device. +## Native Mimir Dashboard + +Start the dashboard authority on Starfire: + +```powershell +dotnet run --project E:\Projects\Mimir\src\Mimir.EveDashboard\Mimir.EveDashboard.csproj -- --port 8795 +``` + +EveCanvas connects to `ws://192.168.1.66:8795/eve/dashboard`. Mimir sends +`dashboard-state` snapshots containing scene nodes, selection, visibility, +transform, size, and health. EveCanvas sends compact commands back: + +- `select` +- `move` +- `scale` +- `rotate` +- `toggle-visibility` +- `reset-transform` + +Eve renders and edits the dashboard; Mimir owns the accepted state. Eve is the +operator's hand on the scene graph, not a second compositor. + ## Build Shape The project is a Theos-style iOS application: @@ -126,3 +152,5 @@ without losing the basic app deployment path. - Add touch/Pencil visual markers. - Replace JSON/base64 sensor packets with binary framing once camera and mic timing are proven through Mimir. +- Replace the dashboard fixture state with live `MimirPresentationControlState` + and `MimirSceneEditorState` snapshots. diff --git a/Sources/EVEDashboardClient.h b/Sources/EVEDashboardClient.h new file mode 100644 index 0000000..5823b59 --- /dev/null +++ b/Sources/EVEDashboardClient.h @@ -0,0 +1,21 @@ +#import + +@protocol EVEDashboardClientDelegate; + +@interface EVEDashboardClient : NSObject + +- (instancetype)initWithURLs:(NSArray *)urls delegate:(id)delegate; +- (void)connect; +- (void)disconnect; +- (void)sendCommand:(NSDictionary *)command; + +@property(nonatomic, copy, readonly) NSString *status; + +@end + +@protocol EVEDashboardClientDelegate + +- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state; +- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status; + +@end diff --git a/Sources/EVEDashboardClient.m b/Sources/EVEDashboardClient.m new file mode 100644 index 0000000..752c932 --- /dev/null +++ b/Sources/EVEDashboardClient.m @@ -0,0 +1,127 @@ +#import "EVEDashboardClient.h" + +@interface EVEDashboardClient () + +@property(nonatomic, copy) NSArray *urls; +@property(nonatomic, assign) NSUInteger urlIndex; +@property(nonatomic, weak) id delegate; +@property(nonatomic, strong) NSURLSession *session; +@property(nonatomic, strong) NSURLSessionWebSocketTask *task; +@property(nonatomic, assign) BOOL shouldReconnect; +@property(nonatomic, copy, readwrite) NSString *status; + +@end + +@implementation EVEDashboardClient + +- (instancetype)initWithURLs:(NSArray *)urls delegate:(id)delegate { + self = [super init]; + if (!self) { + return nil; + } + + self.urls = urls.count > 0 ? urls : @[]; + self.delegate = delegate; + self.session = [NSURLSession sessionWithConfiguration:NSURLSessionConfiguration.defaultSessionConfiguration]; + self.status = @"dashboard idle"; + return self; +} + +- (void)connect { + self.shouldReconnect = YES; + [self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil]; + NSURL *url = self.urls.count > 0 ? self.urls[self.urlIndex % self.urls.count] : nil; + if (!url) { + [self publishStatus:@"dashboard URL missing"]; + return; + } + + self.task = [self.session webSocketTaskWithURL:url]; + [self.task resume]; + [self publishStatus:[NSString stringWithFormat:@"dashboard connecting %@", url.host ?: @""]]; + [self receiveNextMessage]; +} + +- (void)disconnect { + self.shouldReconnect = NO; + [self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil]; + self.task = nil; +} + +- (void)sendCommand:(NSDictionary *)command { + if (!self.task) { + return; + } + + NSData *json = [NSJSONSerialization dataWithJSONObject:command options:0 error:nil]; + if (!json) { + return; + } + + NSString *text = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding]; + NSURLSessionWebSocketMessage *message = [[NSURLSessionWebSocketMessage alloc] initWithString:text]; + [self.task sendMessage:message completionHandler:^(NSError *error) { + if (error) { + [self publishStatus:@"dashboard command failed"]; + } + }]; +} + +- (void)receiveNextMessage { + __weak typeof(self) weakSelf = self; + [self.task receiveMessageWithCompletionHandler:^(NSURLSessionWebSocketMessage *message, NSError *error) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + return; + } + + if (error) { + [self publishStatus:@"dashboard disconnected"]; + [self reconnectSoon]; + return; + } + + if (message.type == NSURLSessionWebSocketMessageTypeString) { + NSData *data = [message.string dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *state = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + if ([state isKindOfClass:NSDictionary.class]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate dashboardClient:self didReceiveState:state]; + [self.delegate dashboardClient:self didChangeStatus:@"dashboard live"]; + }); + } + } + + [self receiveNextMessage]; + }]; +} + +- (void)reconnectSoon { + if (!self.shouldReconnect) { + return; + } + + if (self.urls.count > 1) { + self.urlIndex = (self.urlIndex + 1) % self.urls.count; + } + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (self.shouldReconnect) { + [self connect]; + } + }); +} + +- (void)publishStatus:(NSString *)status { + self.status = status ?: @"dashboard"; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate dashboardClient:self didChangeStatus:self.status]; + }); +} + +- (void)dealloc { + [self disconnect]; + [self.session invalidateAndCancel]; +} + +@end diff --git a/Sources/EVEViewController.mm b/Sources/EVEViewController.mm index 61470f0..989b80c 100644 --- a/Sources/EVEViewController.mm +++ b/Sources/EVEViewController.mm @@ -4,6 +4,7 @@ #import #import "EVEFrameStreamClient.h" +#import "EVEDashboardClient.h" #import "EVEGLView.h" #import "EVEH264StreamDecoder.h" #import "EVESensorUplinkClient.h" @@ -14,15 +15,23 @@ static int64_t EVEHostTimeNowNs(void) { return (int64_t)(CACurrentMediaTime() * 1000000000.0); } -@interface EVEViewController () +@interface EVEViewController () @property(nonatomic, strong) EVEGLView *glView; @property(nonatomic, strong) UIImageView *streamImageView; @property(nonatomic, strong) EVEH264StreamDecoder *videoDecoder; @property(nonatomic, strong) UILabel *overlayLabel; +@property(nonatomic, strong) UIView *dashboardView; +@property(nonatomic, strong) UIView *sceneCanvasView; +@property(nonatomic, strong) UIStackView *hierarchyStackView; +@property(nonatomic, strong) UIStackView *toolbarStackView; +@property(nonatomic, strong) UILabel *dashboardStatusLabel; +@property(nonatomic, strong) NSMutableDictionary *nodeViews; +@property(nonatomic, strong) NSMutableDictionary *dashboardNodes; @property(nonatomic, strong) CADisplayLink *displayLink; @property(nonatomic, strong) CMMotionManager *motionManager; @property(nonatomic, strong) EVEFrameStreamClient *streamClient; +@property(nonatomic, strong) EVEDashboardClient *dashboardClient; @property(nonatomic, strong) EVESensorUplinkClient *cameraUplink; @property(nonatomic, strong) EVESensorUplinkClient *micUplink; @property(nonatomic, strong) AVCaptureSession *captureSession; @@ -39,6 +48,10 @@ static int64_t EVEHostTimeNowNs(void) { @property(nonatomic, copy) NSString *streamStatus; @property(nonatomic, copy) NSString *streamCodec; @property(nonatomic, copy) NSString *dialogueLine; +@property(nonatomic, copy) NSString *dashboardStatus; +@property(nonatomic, copy) NSString *selectedNodeId; +@property(nonatomic, assign) CGFloat activeGestureStartScale; +@property(nonatomic, assign) CGFloat activeGestureStartRotation; @end @@ -65,6 +78,8 @@ static int64_t EVEHostTimeNowNs(void) { self.videoDecoder.displayLayer.hidden = YES; [root.layer addSublayer:self.videoDecoder.displayLayer]; + [self installDashboardInRoot:root]; + self.overlayLabel = [[UILabel alloc] initWithFrame:CGRectZero]; self.overlayLabel.translatesAutoresizingMaskIntoConstraints = NO; self.overlayLabel.numberOfLines = 0; @@ -103,6 +118,9 @@ static int64_t EVEHostTimeNowNs(void) { self.streamStatus = @"stream idle"; self.streamCodec = @"jpeg"; self.dialogueLine = @"awaiting Mimir"; + self.dashboardStatus = @"dashboard idle"; + self.nodeViews = [NSMutableDictionary dictionary]; + self.dashboardNodes = [NSMutableDictionary dictionary]; NSArray *streamURLs = @[ [NSURL URLWithString:@"ws://127.0.0.1:8792/stream"], [NSURL URLWithString:@"ws://192.168.1.66:8792/stream"], @@ -110,6 +128,13 @@ static int64_t EVEHostTimeNowNs(void) { self.streamClient = [[EVEFrameStreamClient alloc] initWithURLs:streamURLs delegate:self]; [self.streamClient connect]; + NSArray *dashboardURLs = @[ + [NSURL URLWithString:@"ws://127.0.0.1:8795/eve/dashboard"], + [NSURL URLWithString:@"ws://192.168.1.66:8795/eve/dashboard"], + ]; + self.dashboardClient = [[EVEDashboardClient alloc] initWithURLs:dashboardURLs delegate:self]; + [self.dashboardClient connect]; + NSArray *cameraURLs = @[ [NSURL URLWithString:@"ws://127.0.0.1:8793/eve/camera"], [NSURL URLWithString:@"ws://192.168.1.66:8793/eve/camera"], @@ -131,6 +156,7 @@ static int64_t EVEHostTimeNowNs(void) { - (void)dealloc { [self.displayLink invalidate]; [self.streamClient disconnect]; + [self.dashboardClient disconnect]; [self.cameraUplink disconnect]; [self.micUplink disconnect]; [self.captureSession stopRunning]; @@ -155,6 +181,9 @@ static int64_t EVEHostTimeNowNs(void) { [super viewDidLayoutSubviews]; [self.glView resizeDrawableIfNeeded]; self.videoDecoder.displayLayer.frame = self.view.bounds; + for (NSString *nodeId in self.dashboardNodes) { + [self updateDashboardNodeView:self.dashboardNodes[nodeId]]; + } } - (void)frameTick:(CADisplayLink *)link { @@ -172,6 +201,12 @@ static int64_t EVEHostTimeNowNs(void) { } - (void)updateOverlay { + if (self.dashboardView) { + self.overlayLabel.hidden = YES; + return; + } + + self.overlayLabel.hidden = NO; UIScreen *screen = UIScreen.mainScreen; CGSize points = screen.bounds.size; CGFloat scale = screen.scale; @@ -186,6 +221,7 @@ static int64_t EVEHostTimeNowNs(void) { "%@\n" "codec %@\n" "Mimir: %@\n" + "dashboard %@\n" "points %.0fx%.0f pixels %.0fx%.0f @ %.1fx\n" "stream %.0fx%.0f @ %.1fx\n" "fps %.1f touches %lu\n" @@ -195,6 +231,7 @@ static int64_t EVEHostTimeNowNs(void) { self.streamStatus ?: @"stream", self.streamCodec ?: @"unknown", self.dialogueLine ?: @"", + self.dashboardStatus ?: @"dashboard", points.width, points.height, pixels.width, pixels.height, scale, self.streamViewportSize.width, self.streamViewportSize.height, self.streamScale, self.filteredFPS, (unsigned long)self.touchCount, @@ -205,6 +242,81 @@ static int64_t EVEHostTimeNowNs(void) { [self.overlayLabel sizeToFit]; } +- (void)installDashboardInRoot:(UIView *)root { + self.dashboardView = [[UIView alloc] initWithFrame:root.bounds]; + self.dashboardView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.dashboardView.backgroundColor = [[UIColor colorWithRed:0.02 green:0.025 blue:0.03 alpha:1.0] colorWithAlphaComponent:0.92]; + [root addSubview:self.dashboardView]; + + self.sceneCanvasView = [[UIView alloc] initWithFrame:CGRectZero]; + self.sceneCanvasView.translatesAutoresizingMaskIntoConstraints = NO; + self.sceneCanvasView.backgroundColor = [UIColor colorWithRed:0.05 green:0.055 blue:0.06 alpha:1.0]; + self.sceneCanvasView.layer.borderColor = [UIColor colorWithWhite:1.0 alpha:0.12].CGColor; + self.sceneCanvasView.layer.borderWidth = 1.0; + self.sceneCanvasView.multipleTouchEnabled = YES; + [self.dashboardView addSubview:self.sceneCanvasView]; + + self.hierarchyStackView = [[UIStackView alloc] initWithFrame:CGRectZero]; + self.hierarchyStackView.translatesAutoresizingMaskIntoConstraints = NO; + self.hierarchyStackView.axis = UILayoutConstraintAxisVertical; + self.hierarchyStackView.spacing = 6.0; + self.hierarchyStackView.alignment = UIStackViewAlignmentFill; + [self.dashboardView addSubview:self.hierarchyStackView]; + + self.toolbarStackView = [[UIStackView alloc] initWithFrame:CGRectZero]; + self.toolbarStackView.translatesAutoresizingMaskIntoConstraints = NO; + self.toolbarStackView.axis = UILayoutConstraintAxisHorizontal; + self.toolbarStackView.spacing = 8.0; + self.toolbarStackView.alignment = UIStackViewAlignmentCenter; + [self.dashboardView addSubview:self.toolbarStackView]; + + self.dashboardStatusLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + self.dashboardStatusLabel.translatesAutoresizingMaskIntoConstraints = NO; + self.dashboardStatusLabel.textColor = [UIColor colorWithWhite:0.90 alpha:1.0]; + self.dashboardStatusLabel.font = [UIFont monospacedSystemFontOfSize:12.0 weight:UIFontWeightRegular]; + self.dashboardStatusLabel.text = @"dashboard idle"; + [self.dashboardView addSubview:self.dashboardStatusLabel]; + + UILayoutGuide *safe = self.dashboardView.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [self.hierarchyStackView.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:14.0], + [self.hierarchyStackView.topAnchor constraintEqualToAnchor:safe.topAnchor constant:74.0], + [self.hierarchyStackView.widthAnchor constraintEqualToConstant:230.0], + [self.hierarchyStackView.bottomAnchor constraintLessThanOrEqualToAnchor:safe.bottomAnchor constant:-18.0], + + [self.sceneCanvasView.leadingAnchor constraintEqualToAnchor:self.hierarchyStackView.trailingAnchor constant:14.0], + [self.sceneCanvasView.topAnchor constraintEqualToAnchor:safe.topAnchor constant:74.0], + [self.sceneCanvasView.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor constant:-14.0], + [self.sceneCanvasView.bottomAnchor constraintEqualToAnchor:safe.bottomAnchor constant:-64.0], + + [self.toolbarStackView.leadingAnchor constraintEqualToAnchor:self.sceneCanvasView.leadingAnchor], + [self.toolbarStackView.topAnchor constraintEqualToAnchor:safe.topAnchor constant:18.0], + [self.toolbarStackView.trailingAnchor constraintLessThanOrEqualToAnchor:safe.trailingAnchor constant:-14.0], + [self.toolbarStackView.heightAnchor constraintEqualToConstant:42.0], + + [self.dashboardStatusLabel.leadingAnchor constraintEqualToAnchor:self.sceneCanvasView.leadingAnchor], + [self.dashboardStatusLabel.topAnchor constraintEqualToAnchor:self.sceneCanvasView.bottomAnchor constant:10.0], + [self.dashboardStatusLabel.trailingAnchor constraintEqualToAnchor:self.sceneCanvasView.trailingAnchor], + ]]; + + NSArray *buttons = @[ + @{@"title": @"Reset", @"action": @"reset-transform"}, + @{@"title": @"Hide/Show", @"action": @"toggle-visibility"}, + ]; + for (NSDictionary *entry in buttons) { + UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem]; + [button setTitle:entry[@"title"] forState:UIControlStateNormal]; + button.accessibilityIdentifier = entry[@"action"]; + button.titleLabel.font = [UIFont systemFontOfSize:15.0 weight:UIFontWeightSemibold]; + button.tintColor = UIColor.whiteColor; + button.backgroundColor = [UIColor colorWithRed:0.16 green:0.18 blue:0.20 alpha:1.0]; + button.layer.cornerRadius = 6.0; + button.contentEdgeInsets = UIEdgeInsetsMake(8, 12, 8, 12); + [button addTarget:self action:@selector(toolbarButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + [self.toolbarStackView addArrangedSubview:button]; + } +} + - (void)startSensorCapture { self.captureQueue = dispatch_queue_create("org.gamecult.evecanvas.camera", DISPATCH_QUEUE_SERIAL); @@ -401,6 +513,235 @@ static int64_t EVEHostTimeNowNs(void) { return CGPointMake(x, y); } +- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state { + (void)client; + self.dashboardStatus = [NSString stringWithFormat:@"v%@", state[@"version"] ?: @"?"]; + NSString *selected = state[@"selectedNodeId"]; + if ([selected isKindOfClass:NSString.class]) { + self.selectedNodeId = selected; + } + + NSArray *nodes = state[@"nodes"]; + if (![nodes isKindOfClass:NSArray.class]) { + return; + } + + [self.dashboardNodes removeAllObjects]; + for (NSDictionary *node in nodes) { + if (![node isKindOfClass:NSDictionary.class]) { + continue; + } + + NSString *nodeId = node[@"id"]; + if (![nodeId isKindOfClass:NSString.class]) { + continue; + } + + self.dashboardNodes[nodeId] = node; + [self ensureDashboardNodeView:node]; + [self updateDashboardNodeView:node]; + } + + [self rebuildHierarchyWithNodes:nodes]; +} + +- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status { + (void)client; + self.dashboardStatus = status; + self.dashboardStatusLabel.text = status; +} + +- (void)ensureDashboardNodeView:(NSDictionary *)node { + NSString *nodeId = node[@"id"]; + if (self.nodeViews[nodeId]) { + return; + } + + UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 120, 80)]; + view.backgroundColor = [UIColor colorWithRed:0.10 green:0.13 blue:0.16 alpha:1.0]; + view.layer.borderWidth = 1.0; + view.layer.cornerRadius = 6.0; + view.multipleTouchEnabled = YES; + view.accessibilityIdentifier = nodeId; + + UILabel *label = [[UILabel alloc] initWithFrame:CGRectInset(view.bounds, 8.0, 6.0)]; + label.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + label.numberOfLines = 3; + label.textColor = UIColor.whiteColor; + label.font = [UIFont monospacedSystemFontOfSize:12.0 weight:UIFontWeightMedium]; + label.tag = 1001; + [view addSubview:label]; + + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(nodeTapped:)]; + [view addGestureRecognizer:tap]; + UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(nodePanned:)]; + [view addGestureRecognizer:pan]; + UIPinchGestureRecognizer *pinch = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(nodePinched:)]; + pinch.delegate = self; + [view addGestureRecognizer:pinch]; + UIRotationGestureRecognizer *rotation = [[UIRotationGestureRecognizer alloc] initWithTarget:self action:@selector(nodeRotated:)]; + rotation.delegate = self; + [view addGestureRecognizer:rotation]; + + [self.sceneCanvasView addSubview:view]; + self.nodeViews[nodeId] = view; +} + +- (void)updateDashboardNodeView:(NSDictionary *)node { + NSString *nodeId = node[@"id"]; + UIView *view = self.nodeViews[nodeId]; + if (!view) { + return; + } + + CGFloat canvasWidth = MAX(1.0, self.sceneCanvasView.bounds.size.width); + CGFloat canvasHeight = MAX(1.0, self.sceneCanvasView.bounds.size.height); + CGFloat width = MAX(72.0, [node[@"width"] doubleValue] * canvasWidth); + CGFloat height = MAX(54.0, [node[@"height"] doubleValue] * canvasHeight); + CGFloat x = ([node[@"x"] doubleValue] + 1.0) * 0.5 * canvasWidth; + CGFloat y = ([node[@"y"] doubleValue] + 1.0) * 0.5 * canvasHeight; + view.bounds = CGRectMake(0, 0, width, height); + view.center = CGPointMake(x, y); + + CGFloat scale = MAX(0.25, [node[@"scale"] doubleValue]); + CGFloat rotation = [node[@"rotation"] doubleValue]; + view.transform = CGAffineTransformRotate(CGAffineTransformMakeScale(scale, scale), rotation); + BOOL visible = [node[@"visible"] boolValue]; + view.alpha = visible ? 1.0 : 0.28; + BOOL selected = [nodeId isEqualToString:self.selectedNodeId]; + view.layer.borderColor = selected + ? [UIColor colorWithRed:0.45 green:0.80 blue:1.0 alpha:1.0].CGColor + : [UIColor colorWithWhite:1.0 alpha:0.16].CGColor; + view.layer.borderWidth = selected ? 2.0 : 1.0; + + UILabel *label = (UILabel *)[view viewWithTag:1001]; + if ([label isKindOfClass:UILabel.class]) { + label.text = [NSString stringWithFormat:@"%@\n%@ %@", + node[@"label"] ?: nodeId, + node[@"kind"] ?: @"source", + node[@"health"] ?: @""]; + } +} + +- (void)rebuildHierarchyWithNodes:(NSArray *)nodes { + for (UIView *view in self.hierarchyStackView.arrangedSubviews) { + [self.hierarchyStackView removeArrangedSubview:view]; + [view removeFromSuperview]; + } + + UILabel *title = [[UILabel alloc] initWithFrame:CGRectZero]; + title.text = @"Scene Graph"; + title.textColor = UIColor.whiteColor; + title.font = [UIFont systemFontOfSize:16.0 weight:UIFontWeightBold]; + [self.hierarchyStackView addArrangedSubview:title]; + + for (NSDictionary *node in nodes) { + NSString *nodeId = node[@"id"]; + if (![nodeId isKindOfClass:NSString.class]) { + continue; + } + + UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem]; + NSString *visible = [node[@"visible"] boolValue] ? @"●" : @"○"; + [button setTitle:[NSString stringWithFormat:@"%@ %@", visible, node[@"label"] ?: nodeId] forState:UIControlStateNormal]; + button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; + button.accessibilityIdentifier = nodeId; + button.titleLabel.font = [UIFont monospacedSystemFontOfSize:13.0 weight:[nodeId isEqualToString:self.selectedNodeId] ? UIFontWeightBold : UIFontWeightRegular]; + button.tintColor = [nodeId isEqualToString:self.selectedNodeId] ? [UIColor colorWithRed:0.55 green:0.85 blue:1.0 alpha:1.0] : [UIColor colorWithWhite:0.85 alpha:1.0]; + [button addTarget:self action:@selector(hierarchyNodePressed:) forControlEvents:UIControlEventTouchUpInside]; + [self.hierarchyStackView addArrangedSubview:button]; + } +} + +- (void)nodeTapped:(UITapGestureRecognizer *)recognizer { + NSString *nodeId = recognizer.view.accessibilityIdentifier; + if (!nodeId) { + return; + } + + self.selectedNodeId = nodeId; + [self.dashboardClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}]; +} + +- (void)nodePanned:(UIPanGestureRecognizer *)recognizer { + UIView *view = recognizer.view; + NSString *nodeId = view.accessibilityIdentifier; + if (!nodeId) { + return; + } + + CGPoint translation = [recognizer translationInView:self.sceneCanvasView]; + view.center = CGPointMake(view.center.x + translation.x, view.center.y + translation.y); + [recognizer setTranslation:CGPointZero inView:self.sceneCanvasView]; + if (recognizer.state == UIGestureRecognizerStateChanged || recognizer.state == UIGestureRecognizerStateEnded) { + [self sendMoveForNodeId:nodeId center:view.center]; + } +} + +- (void)nodePinched:(UIPinchGestureRecognizer *)recognizer { + NSString *nodeId = recognizer.view.accessibilityIdentifier; + NSDictionary *node = nodeId ? self.dashboardNodes[nodeId] : nil; + if (!nodeId || !node) { + return; + } + + if (recognizer.state == UIGestureRecognizerStateBegan) { + self.activeGestureStartScale = MAX(0.25, [node[@"scale"] doubleValue]); + } + + CGFloat scale = MAX(0.25, MIN(3.0, self.activeGestureStartScale * recognizer.scale)); + [self.dashboardClient sendCommand:@{@"type": @"scale", @"nodeId": nodeId, @"scale": @(scale)}]; +} + +- (void)nodeRotated:(UIRotationGestureRecognizer *)recognizer { + NSString *nodeId = recognizer.view.accessibilityIdentifier; + NSDictionary *node = nodeId ? self.dashboardNodes[nodeId] : nil; + if (!nodeId || !node) { + return; + } + + if (recognizer.state == UIGestureRecognizerStateBegan) { + self.activeGestureStartRotation = [node[@"rotation"] doubleValue]; + } + + CGFloat rotation = self.activeGestureStartRotation + recognizer.rotation; + [self.dashboardClient sendCommand:@{@"type": @"rotate", @"nodeId": nodeId, @"rotation": @(rotation)}]; +} + +- (void)sendMoveForNodeId:(NSString *)nodeId center:(CGPoint)center { + CGFloat canvasWidth = MAX(1.0, self.sceneCanvasView.bounds.size.width); + CGFloat canvasHeight = MAX(1.0, self.sceneCanvasView.bounds.size.height); + CGFloat x = MIN(1.0, MAX(-1.0, (center.x / canvasWidth) * 2.0 - 1.0)); + CGFloat y = MIN(1.0, MAX(-1.0, (center.y / canvasHeight) * 2.0 - 1.0)); + [self.dashboardClient sendCommand:@{@"type": @"move", @"nodeId": nodeId, @"x": @(x), @"y": @(y)}]; +} + +- (void)hierarchyNodePressed:(UIButton *)button { + NSString *nodeId = button.accessibilityIdentifier; + if (!nodeId) { + return; + } + + self.selectedNodeId = nodeId; + [self.dashboardClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}]; +} + +- (void)toolbarButtonPressed:(UIButton *)button { + NSString *nodeId = self.selectedNodeId; + NSString *action = button.accessibilityIdentifier; + if (!nodeId || !action) { + return; + } + + [self.dashboardClient sendCommand:@{@"type": action, @"nodeId": nodeId}]; +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { + (void)gestureRecognizer; + (void)otherGestureRecognizer; + return YES; +} + - (void)sendTouches:(NSSet *)touches event:(UIEvent *)event phase:(NSString *)phase { (void)event; UITouch *touch = touches.anyObject;