奇跡の軌跡

AppKitのリハビリ代わりに、2011年8月ごろのコマ大でやってたらしい「奇跡の軌跡」って問題をやるよ。

こういう問題だったらしい:
■コマ大数学科:奇跡の軌跡: ガスコン研究所」より

問題:図のように3つの車輪が異なるサイズで、車輪Cは車輪Bに、車輪Bは車輪Aに取り付けられていて、それぞれ異なる速さで回転する観覧車がある。乗客が1番小さい車輪に乗った時、その乗客の通過する軌跡を描きなさい。

車輪Aは、ゆっくりと反時計回り
車輪Bは、車輪Aの7倍の速さで反時計回り
車輪Cは、車輪Aの17倍の速さで時計回り
車輪Bは、車輪Aの半分の半径
車輪Cは、車輪Aの3分の1の半径

方針

車輪A>車輪B>車輪C>ゴンドラがViewの階層となるようにして、それぞれの原点を回転(円状にスライド)させようと思う。最初座標系ごと回転させてたけど、「7倍」とか「17倍」とかいうのが絶対者視点の回転速度なのでしっくりこなかったのでやめた。


NSAnimationを使って車輪Aの一周分の描画を行う。progressが0.0〜1.0の値を取るので、そこから角度を計算する時に7倍とか17倍する。

ソースコード

テキストエディットを開いて下のプログラムを貼付けるよ。

#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>

static NSString *FrameBaseAnimationFrame = @"FrameBaseAnimationFrame";
static NSString *AnimationProgressKey = @"NSAnimationProgress";

// 毎フレーム通知を投げるAnimationクラス
@interface FrameBaseAnimation : NSAnimation
{
}
@end
@implementation FrameBaseAnimation
// フレーム毎に呼ばれるメソッドをオーバーライドして通知を投げるようにする
- (void)setCurrentProgress:(NSAnimationProgress)progress
{
    [super setCurrentProgress:progress];
    NSDictionary *userInfo =
        [NSDictionary dictionaryWithObject:[NSNumber numberWithFloat:progress]
                                    forKey:AnimationProgressKey];
    [[NSNotificationCenter defaultCenter]
        postNotificationName:FrameBaseAnimationFrame object:self userInfo:userInfo];
}
// モーダルループでも動けるようにオーバーライド
- (NSArray *)runLoopModesForAnimating
{
    return [NSArray arrayWithObjects:NSDefaultRunLoopMode, NSRunLoopCommonModes, nil];
}
@end

// 原点が円状に移動するView
@interface WheelView : NSView
{
    float frequency; // progressあたりの回転数
    float wheelRadius; // ホイールの半径
}
@property float frequency;
@property float wheelRadius;
- (void)setFrequency:(float)freq;
- (void)setWheelRadius:(float)freq;
- (void)observeAnimation:(FrameBaseAnimation *)animation;
- (void)updateBoundsOriginWithProgress:(NSAnimationProgress) progress;
@end

@implementation WheelView
@synthesize frequency;
@synthesize wheelRadius;
+ (WheelView *)wheelViewWithWheelRadius:(float)radius frequency:(float)freq
{
    NSRect frameRect = NSMakeRect(-2 * radius, -2 * radius, 4 * radius, 4 * radius);
    WheelView *view = [[[WheelView alloc] initWithFrame:frameRect] autorelease];
    if (view != nil) {
        [view setWheelRadius:radius];
        [view setFrequency:freq];
        [view updateBoundsOriginWithProgress:0];
    }
    return view;
}
- (void)observeAnimation:(FrameBaseAnimation *)animation
{
    [[NSNotificationCenter defaultCenter]
        addObserver:self
           selector:@selector(updateBoundsOrigin:)
               name:FrameBaseAnimationFrame
             object:animation];
}
- (void)updateBoundsOrigin:(NSNotification *)notification
{
    NSAnimationProgress progress =
        [[[notification userInfo] objectForKey:AnimationProgressKey] floatValue];
    [self updateBoundsOriginWithProgress:progress];
}
- (void)updateBoundsOriginWithProgress:(NSAnimationProgress) progress
{
    float angle = 2 * M_PI * frequency * progress;
    NSPoint newBoundsOrigin = [self frame].origin;
    newBoundsOrigin.x  -= wheelRadius * cos(angle);
    newBoundsOrigin.y  -= wheelRadius * sin(angle);
//    [self setBoundsOrigin:newBoundsOrigin]; // ← ダメだった
    NSPoint boundsOrigin = [self bounds].origin;
    [self translateOriginToPoint:
            NSMakePoint(boundsOrigin.x - newBoundsOrigin.x,
                        boundsOrigin.y - newBoundsOrigin.y)];
}
// 画面をダブルクリックすると終了する
- (void)mouseUp:(NSEvent*)theEvent
{
    if ([theEvent clickCount] >= 2) {
        [[NSApplication sharedApplication] stopModal];
    }
}
- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    [super dealloc];
}
@end
// ゴンドラを表すView
@interface GondolaView : NSView
{
}
@end
@implementation GondolaView
- (void)observeAnimation:(FrameBaseAnimation *)animation
{
    [[NSNotificationCenter defaultCenter]
         addObserver:self
            selector:@selector(drawPoint:)
                name:FrameBaseAnimationFrame
              object:animation];
}
- (void)drawPoint:(NSNotification *)notification
{
    [self lockFocus];
    [[NSColor blackColor] set];
    NSRectFill([self bounds]);
    [self unlockFocus];

    [[self window] flushWindow];
}
- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    [super dealloc];
}
@end

int
main (int argc, const char * argv[]) {
    int radiusUnit = 120; // 車輪Aの半径

    NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init];
    NSApplication *application = [NSApplication sharedApplication];

    NSRect contentRect = NSMakeRect(0, 0, 4 * radiusUnit, 4 * radiusUnit);
    NSWindow *window = [[[NSWindow alloc]
                         initWithContentRect:contentRect
                                   styleMask:NSTitledWindowMask
                                     backing:NSBackingStoreBuffered defer:NO] autorelease];
    [window setTitle:@"Locus Mirabilis"];
    [window setMovableByWindowBackground:YES]; // どこでもつかんで動かせるようにする

    // contentViewの中央に原点を設定
    NSView *contentView = [window contentView];
    [contentView setBoundsOrigin:NSMakePoint(-2 * radiusUnit, -2 * radiusUnit)];
    
    // 各車輪にあたるViewを作成
    WheelView *wheelAView = [WheelView wheelViewWithWheelRadius:radiusUnit frequency:1];
    [contentView addSubview:wheelAView];
    WheelView *wheelBView = [WheelView wheelViewWithWheelRadius:radiusUnit/2 frequency:7];
    [wheelAView addSubview:wheelBView];
    WheelView *wheelCView = [WheelView wheelViewWithWheelRadius:radiusUnit/3 frequency:-17];
    [wheelBView addSubview:wheelCView];
    // ゴンドラにあたるViewを作成
    GondolaView *gondloaView = 
        [[[GondolaView alloc] initWithFrame:NSMakeRect(-1, -1, 2,2)] autorelease];
    [wheelCView addSubview:gondloaView];

    [contentView display];

    // 一周分のアニメーションをおこなうAnimationを作成
    NSTimeInterval duration = 30.0; // 30秒で一周
    FrameBaseAnimation *animation =
        [[FrameBaseAnimation alloc] initWithDuration:duration
                                      animationCurve:NSAnimationEaseInOut];
    [animation setAnimationBlockingMode:NSAnimationNonblocking];
    [animation setFrameRate:60.0]; // 一秒間に60回呼ぶ。0.0なら全力

    [wheelAView observeAnimation:animation];
    [wheelBView observeAnimation:animation];
    [wheelCView observeAnimation:animation];
    [gondloaView observeAnimation:animation];

    [animation startAnimation];

    // ダブルクリックされるまでモーダル
    [application runModalForWindow:window];

    [pool drain];
    return 0;
}

LocusMirabilis.mとかいうようなファイル名で保存したら、下のコマンドを打ち込んでコンパイルする。

gcc -ObjC -Wall -framework AppKit -framework Foundation -o LocusMirabilis LocusMirabilis.m 

コンパイルが成功したらコマンドラインから起動する。

./LocusMirabilis

ダブルクリックで終了しますよ。

解説

NSAnimationは「Animation Programming Guide for Cocoa」に書いてあった通りにサブクラスを作った。-setCurrentProgress:が毎フレーム呼ばれるらしいのでそれをオーバーライドして描画をおこなっている。


複数のViewを更新するので、NSNotificationCenterを使って通知を投げて受け取るようにしている。
厳密には順序に依存するので自前でリストを管理した方が良いような気もする。


点の描画はdrawRect:をオーバーライドするのではなくて、viewにlockFocusして一時的に描画している。windowをflushWindowすれば画面上は更新される。displayしたら消えてしまう。


各車輪にあたるViewは、座標系自体を円状にスライドしている(回転はしていない。)*1
setBoundsOrigin:はドキュメントによるとneedsDisplayのフラグを立てないらしいが、実際に呼んでみると立てるので画面がクリアされてしまう。
translateOriginToPoint:はフラグが立たないのでこっちを使った。


propertyは使ってみたけど使い方あってるかどうか分からん。

余談

プログラミング言語をより深く理解する為に、IDEに頼らずコマンドラインコンパイル・実行できるようになるべき(キリッ」って意見たまにみるけど、Objective-Cでそれ実践してる人はあんまり居ないような気がする。


個人的にはIDE使えるならIDE使えば良いと思う。

*1:モデリング的なことを言えば車輪Aの原点は車輪Aの中心にあって、A上でBを回すようにしたかったが、今回はそこが本筋じゃないのでパスした。