Skip to content
Merged
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
103 changes: 89 additions & 14 deletions src/screen-name/model/SimModel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BooleanProperty, Property } from "scenerystack/axon";
import { Matrix3, Transform3 } from "scenerystack/dot";
import { BooleanProperty, DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon";
import { Matrix3, Range, Transform3, Vector2 } from "scenerystack/dot";
import type { Track, TrackPoint } from "./Track.js";

// ── Track colour palette (one per letter, repeats after 8) ─────────────────
Expand All @@ -14,30 +14,99 @@ const TRACK_COLORS = [
'#76FF03', // H – light green
];

// ── Calibration unit type ──────────────────────────────────────────────────
export const CALIBRATION_UNITS = [ 'mm', 'cm', 'm', 'km', 'in', 'ft' ] as const;
export type CalibrationUnit = typeof CALIBRATION_UNITS[ number ];
export const CALIBRATION_DISTANCE_RANGE = new Range( 0.001, 100000 );

// ── Layout constants ───────────────────────────────────────────────────────
// SceneryStack's ScreenView.DEFAULT_LAYOUT_BOUNDS = Bounds2(0, 0, 1024, 618).
// The VideoPlayerNode is centered at layoutBounds.center + (0, -20).
const LAYOUT_CENTER_X = 512; // 1024 / 2
const LAYOUT_CENTER_Y = 309; // 618 / 2
const VIDEO_CENTER_X = LAYOUT_CENTER_X; // 512
const VIDEO_CENTER_Y = LAYOUT_CENTER_Y - 20; // 289
const VIDEO_WIDTH = 640;
const VIDEO_HEIGHT = 360;
const CALIB_HALF_LEN = 100; // pixels from center to each calibration endpoint

// Initial tool positions (view / pixel space)
const COORD_ORIGIN_INITIAL = new Vector2( VIDEO_CENTER_X - VIDEO_WIDTH / 4, VIDEO_CENTER_Y );
const CALIB_CENTER_INITIAL = new Vector2( VIDEO_CENTER_X, VIDEO_CENTER_Y + VIDEO_HEIGHT / 4 );
const CALIB_P1_INITIAL = CALIB_CENTER_INITIAL.plusXY( -CALIB_HALF_LEN, 0 );
const CALIB_P2_INITIAL = CALIB_CENTER_INITIAL.plusXY( CALIB_HALF_LEN, 0 );

// ── Model-view transform builder ───────────────────────────────────────────
/**
* Builds a Transform3 from the coordinate-system tool and calibration tool.
*
* Composed as: T(origin) · R(θ) · S(s, −s)
*
* S(s, −s) — scale model units to pixels, flip Y (model +y points up)
* R(θ) — rotate by the coord-system angle (clockwise on screen)
* T(origin) — translate so model origin lands on the coord-system view position
*
* where s = |p2 − p1| / calibrationDistance (pixels per model unit).
* Returns the identity transform when the calibration segment has zero length.
*/
function buildModelViewTransform(
origin: Vector2,
angle: number,
p1: Vector2,
p2: Vector2,
dist: number
): Transform3 {
const pixelDist = p1.distance( p2 );
if ( pixelDist < 1e-6 || dist < 1e-9 ) {
return new Transform3( Matrix3.IDENTITY );
}
const s = pixelDist / dist; // pixels per model unit

const matrix = Matrix3.translationFromVector( origin )
.timesMatrix( Matrix3.rotation2( angle ) )
.timesMatrix( Matrix3.scaling( s, -s ) );

return new Transform3( matrix );
}

export class SimModel {
public readonly isPlayingProperty = new BooleanProperty( false );
public readonly currentTimeProperty = new Property<number>( 0 );
public readonly durationProperty = new Property<number>( 0 );
public readonly videoUrlProperty = new Property<string | null>( null );
public readonly isPlayingProperty = new BooleanProperty( false );
public readonly currentTimeProperty = new Property<number>( 0 );
public readonly durationProperty = new Property<number>( 0 );
public readonly videoUrlProperty = new Property<string | null>( null );

// ── Overlay visibility ────────────────────────────────────────────────
public readonly axesVisibleProperty = new BooleanProperty( true );
public readonly axesVisibleProperty = new BooleanProperty( true );
public readonly calibrationVisibleProperty = new BooleanProperty( true );

// ── Future features (not yet implemented) ────────────────────────────
public readonly magnifyVideoProperty = new BooleanProperty( false );
public readonly autoTrackingProperty = new BooleanProperty( false );

// Maps between real-world model coordinates and view (pixel) coordinates.
// Updated by SimScreenView whenever the coordinate system or calibration tool changes.
public readonly modelViewTransformProperty = new Property<Transform3>(
new Transform3( Matrix3.IDENTITY )
// ── Coordinate system tool state (view / pixel space) ─────────────────
public readonly coordOriginProperty = new Property<Vector2>( COORD_ORIGIN_INITIAL.copy() );
public readonly coordAngleProperty = new NumberProperty( 0 );

// ── Calibration tool state ────────────────────────────────────────────
public readonly calibPoint1Property = new Property<Vector2>( CALIB_P1_INITIAL.copy() );
public readonly calibPoint2Property = new Property<Vector2>( CALIB_P2_INITIAL.copy() );
public readonly calibDistanceProperty = new NumberProperty( 1, { range: CALIBRATION_DISTANCE_RANGE } );
public readonly calibUnitProperty = new Property<CalibrationUnit>( 'm' );

// ── Model-view transform (derived; the view never writes to this) ─────
public readonly modelViewTransformProperty: TReadOnlyProperty<Transform3> = new DerivedProperty(
[
this.coordOriginProperty,
this.coordAngleProperty,
this.calibPoint1Property,
this.calibPoint2Property,
this.calibDistanceProperty,
],
( origin, angle, p1, p2, dist ) => buildModelViewTransform( origin, angle, p1, p2, dist )
);

// ── Manual particle tracks ────────────────────────────────────────────
public readonly tracksProperty = new Property<readonly Track[]>( [] );

// The track currently selected for manual digitizing (null = none).
public readonly tracksProperty = new Property<readonly Track[]>( [] );
public readonly activeTrackIdProperty = new Property<string | null>( null );
private nextSymbolCode = 65; // ASCII code for 'A'

Expand Down Expand Up @@ -91,6 +160,12 @@ export class SimModel {
this.calibrationVisibleProperty.reset();
this.magnifyVideoProperty.reset();
this.autoTrackingProperty.reset();
this.coordOriginProperty.reset();
this.coordAngleProperty.reset();
this.calibPoint1Property.reset();
this.calibPoint2Property.reset();
this.calibDistanceProperty.reset();
this.calibUnitProperty.reset();
this.tracksProperty.value = [];
this.activeTrackIdProperty.value = null;
this.nextSymbolCode = 65;
Expand Down
27 changes: 25 additions & 2 deletions src/screen-name/view/AutoTrackerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { Vector2 } from "scenerystack/dot";
import type { TReadOnlyProperty } from "scenerystack/axon";
import { OpenCVTracker } from "../../tracking/OpenCVTracker.js";
import TrackLabColors from "../../TrackLabColors.js";
import type { SimModel } from "../model/SimModel.js";

const VIDEO_W = 640;
const VIDEO_H = 360;
const MAX_TRAIL = 150;
const CROSSHAIR_SIZE = 16;
const FRAME_DURATION = 1 / 30; // assumes 30 fps

/**
* Transparent SceneryStack overlay that sits directly on top of the video element.
Expand All @@ -22,6 +24,8 @@ const CROSSHAIR_SIZE = 16;
* 4. On each video frame (timeupdate / seeked), the best-match position is
* computed via OpenCV template matching and shown as a red crosshair.
* 5. Past positions are shown as a green trail of dots.
* 6. If a track is active (model.activeTrackIdProperty), each new unique frame
* position is recorded to the model via addPointToTrack.
*
* Local coordinates of this node correspond directly to video-pixel coordinates
* (0,0 = top-left of video) because it is added to the same layer as the video
Expand All @@ -43,7 +47,8 @@ export class AutoTrackerNode extends Node {

public constructor(
videoElement: HTMLVideoElement,
autoTrackingShownProperty: TReadOnlyProperty<boolean>
autoTrackingShownProperty: TReadOnlyProperty<boolean>,
model: SimModel
) {
super( { visible: false } );

Expand Down Expand Up @@ -170,7 +175,25 @@ export class AutoTrackerNode extends Node {

this.trail.push( pt );
if ( this.trail.length > MAX_TRAIL ) this.trail.shift();
this.updateTrackerVisuals( pt );
this.updateTrackerVisuals( pt );

// ── Record position to model if a track is active ─────────────────
const activeId = model.activeTrackIdProperty.value;
if ( activeId ) {
const time = videoElement.currentTime;
const frame = Math.round( time / FRAME_DURATION );

// Avoid duplicate points for the same frame.
const activeTrack = model.tracksProperty.value.find( t => t.id === activeId );
const alreadyRecorded = activeTrack ? activeTrack.points.some( p => p.frame === frame ) : false;

if ( !alreadyRecorded ) {
// Convert video-pixel coords to global coords, then to model coords.
const globalPt = this.localToGlobalPoint( new Vector2( pt.x, pt.y ) );
const modelPt = model.modelViewTransformProperty.value.inversePosition2( globalPt );
model.addPointToTrack( activeId, frame, time, modelPt.x, modelPt.y );
}
}
};
videoElement.addEventListener( 'timeupdate', onFrame );
videoElement.addEventListener( 'seeked', onFrame );
Expand Down
77 changes: 27 additions & 50 deletions src/screen-name/view/CalibrationToolNode.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,25 @@
import { Circle, HBox, Line, Node, RichDragListener, Text } from "scenerystack/scenery";
import { Keypad, PhetFont } from "scenerystack/scenery-phet";
import { KeypadDialog } from "scenerystack/sim";
import { Range, type Vector2 } from "scenerystack/dot";
import { DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon";
import { DerivedProperty } from "scenerystack/axon";
import type { TReadOnlyProperty } from "scenerystack/axon";
import { ComboBox, type ComboBoxItem, Panel, TextPushButton } from "scenerystack/sun";
import { Tandem } from "scenerystack/tandem";
import type { SimModel } from "../model/SimModel.js";
import { CALIBRATION_UNITS } from "../model/SimModel.js";
import TrackLabColors from "../../TrackLabColors.js";

const FONT = new PhetFont( 14 );
const ENDPOINT_RADIUS = 8;
const INITIAL_HALF_LENGTH = 100; // pixels from center to each endpoint

const UNITS = [ 'mm', 'cm', 'm', 'km', 'in', 'ft' ] as const;
type Unit = typeof UNITS[ number ];

const DISTANCE_RANGE = new Range( 0.001, 100000 );

export class CalibrationToolNode extends Node {
public readonly distanceProperty: NumberProperty;
public readonly unitProperty: Property<Unit>;
public readonly point1Property: Property<Vector2>;
public readonly point2Property: Property<Vector2>;

public constructor(
videoLoadedProperty: TReadOnlyProperty<boolean>,
listParent: Node,
initialCenter: Vector2
model: SimModel
) {
super();

this.point1Property = new Property<Vector2>( initialCenter.plusXY( -INITIAL_HALF_LENGTH, 0 ) );
this.point2Property = new Property<Vector2>( initialCenter.plusXY( INITIAL_HALF_LENGTH, 0 ) );
this.distanceProperty = new NumberProperty( 1, { range: DISTANCE_RANGE } );
this.unitProperty = new Property<Unit>( 'm' );

// ── Connecting line ────────────────────────────────────────────────────
const calibrationLine = new Line( 0, 0, 0, 0, {
stroke: TrackLabColors.calibrationStrokeProperty,
Expand Down Expand Up @@ -69,15 +55,15 @@ export class CalibrationToolNode extends Node {
} );
// Pattern shown inside the dialog as "Range: {{min}} – {{max}} <unit>"
const rangePatternProperty = new DerivedProperty(
[ this.unitProperty ],
( unit: Unit ) => `{{min}} – {{max}} ${unit}`
[ model.calibUnitProperty ],
unit => `{{min}} – {{max}} ${ unit }`
);

// ── Midpoint panel ────────────────────────────────────────────────────
// Button showing current value + unit; clicking it opens the keypad.
const buttonLabelProperty = new DerivedProperty(
[ this.distanceProperty, this.unitProperty ],
( dist: number, unit: Unit ) => `${dist.toFixed( 2 )} ${unit}`
[ model.calibDistanceProperty, model.calibUnitProperty ],
( dist, unit ) => `${ dist.toFixed( 2 ) } ${ unit }`
);

const distanceButton = new TextPushButton( buttonLabelProperty, {
Expand All @@ -86,8 +72,8 @@ export class CalibrationToolNode extends Node {
textFill: TrackLabColors.textOnDarkProperty,
listener: () => {
keypadDialog.beginEdit(
( value: number ) => { this.distanceProperty.value = value; },
DISTANCE_RANGE,
( value: number ) => { model.calibDistanceProperty.value = value; },
model.calibDistanceProperty.range,
rangePatternProperty,
() => {}
);
Expand All @@ -96,12 +82,12 @@ export class CalibrationToolNode extends Node {
} );

// Unit selector
const unitItems: ComboBoxItem<Unit>[] = UNITS.map( unit => ( {
const unitItems: ComboBoxItem<typeof CALIBRATION_UNITS[ number ]>[] = CALIBRATION_UNITS.map( unit => ( {
value: unit,
createNode: () => new Text( unit, { font: FONT } ),
tandemName: `${ unit }Item`,
} ) );
const unitComboBox = new ComboBox( this.unitProperty, unitItems, listParent, {
const unitComboBox = new ComboBox( model.calibUnitProperty, unitItems, listParent, {
tandem: Tandem.OPT_OUT,
} );

Expand All @@ -124,40 +110,31 @@ export class CalibrationToolNode extends Node {

// ── Update geometry when endpoints move ───────────────────────────────
const updateGeometry = () => {
const p1 = this.point1Property.value;
const p2 = this.point2Property.value;
const p1 = model.calibPoint1Property.value;
const p2 = model.calibPoint2Property.value;
calibrationLine.setLine( p1.x, p1.y, p2.x, p2.y );
endpoint1.translation = p1;
endpoint2.translation = p2;
const mid = p1.blend( p2, 0.5 );
midpointPanel.centerX = mid.x;
midpointPanel.bottom = mid.y - 12;
};
this.point1Property.link( updateGeometry );
this.point2Property.link( updateGeometry );
model.calibPoint1Property.link( updateGeometry );
model.calibPoint2Property.link( updateGeometry );

// ── Drag listeners for endpoints ──────────────────────────────────────
const makeDragListener = ( pointProperty: Property<Vector2> ) => {
return new RichDragListener( {
positionProperty: pointProperty,
keyboardDragListenerOptions: {
dragSpeed: 200,
shiftDragSpeed: 40,
},
tandem: Tandem.OPT_OUT,
} );
};
endpoint1.addInputListener( makeDragListener( this.point1Property ) );
endpoint2.addInputListener( makeDragListener( this.point2Property ) );
endpoint1.addInputListener( new RichDragListener( {
positionProperty: model.calibPoint1Property,
keyboardDragListenerOptions: { dragSpeed: 200, shiftDragSpeed: 40 },
tandem: Tandem.OPT_OUT,
} ) );
endpoint2.addInputListener( new RichDragListener( {
positionProperty: model.calibPoint2Property,
keyboardDragListenerOptions: { dragSpeed: 200, shiftDragSpeed: 40 },
tandem: Tandem.OPT_OUT,
} ) );

// ── Visibility ─────────────────────────────────────────────────────────
videoLoadedProperty.link( loaded => { this.visible = loaded; } );
}

public reset(): void {
this.point1Property.reset();
this.point2Property.reset();
this.distanceProperty.reset();
this.unitProperty.reset();
}
}
Loading
Loading