Use raycast cursor & level Y fallback

Ceiling and slab tools: switch cursor tracking to per-frame raycasting onto a level plane (useFrame) instead of relying on grid:move events, and memoize Raycaster/Plane to reduce allocations. Update line geometry updates to be performed imperatively from the frame loop, ensure previews only trigger React updates when the snapped display position changes, and keep cursor/preview snapping consistent with user-visible pointer. Add sceneRegistry-based fallback to resolve a level's Y position (used by Grid, CeilingTool and SlabTool) when the level mesh isn't registered yet, computing Y from level node data and viewer levelMode. Misc: import/add necessary three.js types and small refactors to visibility/reset logic.
This commit is contained in:
Aymeric Rabot
2026-02-07 11:58:17 -05:00
parent a7683924a6
commit 31a560e2c5
4 changed files with 324 additions and 222 deletions
+9
View File
@@ -148,6 +148,15 @@ const Grid = ({
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
} else {
// Fallback: compute from level node data when mesh isn't registered yet
const levelNode = useScene.getState().nodes[currentLevelId]
if (levelNode && 'level' in levelNode) {
const levelMode = useViewer.getState().levelMode
const LEVEL_HEIGHT = 2.5
const EXPLODED_GAP = 5
targetY = ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0))
}
}
}
gridRef.current.position.y = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
@@ -1,11 +1,13 @@
import { emitter, type GridEvent, useScene, CeilingNode, type LevelNode } from "@pascal-app/core";
import { emitter, type GridEvent, useScene, CeilingNode, type LevelNode, sceneRegistry } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useFrame } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react";
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three";
import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three";
import useEditor from "@/store/use-editor";
const CEILING_HEIGHT = 2.52; // Slightly above default ceiling height
const GRID_OFFSET = 0.02; // Small offset above floor level
const UP = new Vector3(0, 1, 0);
/**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
@@ -87,10 +89,17 @@ export const CeilingTool: React.FC = () => {
const mainLineRef = useRef<Line>(null!);
const closingLineRef = useRef<Line>(null!);
const pointsRef = useRef<Array<[number, number]>>([]);
const levelYRef = useRef(0); // Track current level Y position
const levelYRef = useRef(0);
const cursorPositionRef = useRef<[number, number]>([0, 0]);
const lastDisplayRef = useRef<{ x: number; z: number } | null>(null);
const currentLevelId = useViewer((state) => state.selection.levelId);
const setTool = useEditor((state) => state.setTool);
// Reusable objects for raycasting (created once, avoid per-frame allocations)
const raycaster = useMemo(() => new Raycaster(), []);
const levelPlane = useMemo(() => new Plane(UP, 0), []);
const hitPoint = useMemo(() => new Vector3(), []);
// Preview state for reactive rendering (for shape and point markers)
const [preview, setPreview] = useState<PreviewState>({
points: [],
@@ -98,118 +107,154 @@ export const CeilingTool: React.FC = () => {
levelY: 0,
});
// Resolve the current level's Y position from scene registry or node data
const getLevelY = (): number => {
if (!currentLevelId) return 0;
const levelMesh = sceneRegistry.nodes.get(currentLevelId);
if (levelMesh) return levelMesh.position.y;
const levelNode = useScene.getState().nodes[currentLevelId];
if (levelNode && 'level' in levelNode) {
const levelMode = useViewer.getState().levelMode;
const LEVEL_HEIGHT = 2.5;
const EXPLODED_GAP = 5;
return ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0));
}
return 0;
};
// Imperatively update line geometries (called from useFrame)
const updateLines = () => {
if (!mainLineRef.current || !closingLineRef.current) return;
const points = pointsRef.current;
const cursorPosition = cursorPositionRef.current;
const ceilingY = levelYRef.current + CEILING_HEIGHT;
if (points.length === 0) {
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
return;
}
// Build main line points
const linePoints: Vector3[] = points.map(
([x, z]) => new Vector3(x, ceilingY, z)
);
// Add cursor point
const lastPoint = points[points.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], ceilingY, snapped[1]));
}
}
// Update main line geometry
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose();
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
mainLineRef.current.visible = true;
} else {
mainLineRef.current.visible = false;
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0];
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
const closingPoints = [
new Vector3(snapped[0], ceilingY, snapped[1]),
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
];
closingLineRef.current.geometry.dispose();
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
closingLineRef.current.visible = true;
}
} else {
closingLineRef.current.visible = false;
}
};
// Per-frame cursor positioning via direct raycasting onto the level plane.
// This bypasses R3F's event propagation (grid:move), ensuring the cursor
// always tracks the pointer regardless of tool activation/deactivation state.
useFrame((state) => {
if (!cursorRef.current) return;
// Sync level Y from scene registry each frame
levelYRef.current = getLevelY();
// Raycast from camera through pointer onto horizontal plane at level Y
raycaster.setFromCamera(state.pointer, state.camera);
levelPlane.constant = -levelYRef.current;
const hit = raycaster.ray.intersectPlane(levelPlane, hitPoint);
if (!hit) return;
// Snap to 0.5m grid
const gridX = Math.round(hit.x * 2) / 2;
const gridZ = Math.round(hit.z * 2) / 2;
cursorPositionRef.current = [gridX, gridZ];
const ceilingY = levelYRef.current + CEILING_HEIGHT;
const gridY = levelYRef.current + GRID_OFFSET;
// Apply axis snapping from last placed point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
let displayPoint: [number, number];
if (lastPoint) {
displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current);
} else {
displayPoint = [gridX, gridZ];
}
// Update cursor mesh positions imperatively
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]);
if (gridCursorRef.current) {
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]);
}
// Update line geometries imperatively
updateLines();
// Only trigger React state update when snapped display position changes
if (
!lastDisplayRef.current ||
lastDisplayRef.current.x !== displayPoint[0] ||
lastDisplayRef.current.z !== displayPoint[1]
) {
lastDisplayRef.current = { x: displayPoint[0], z: displayPoint[1] };
setPreview({
points: [...pointsRef.current],
cursorPoint: displayPoint,
levelY: levelYRef.current,
});
}
});
// Click and double-click handlers for point placement
useEffect(() => {
if (!currentLevelId) return;
let cursorPosition: [number, number] = [0, 0];
// Initialize line geometries
mainLineRef.current.geometry = new BufferGeometry();
closingLineRef.current.geometry = new BufferGeometry();
if (mainLineRef.current) {
mainLineRef.current.geometry = new BufferGeometry();
}
if (closingLineRef.current) {
closingLineRef.current.geometry = new BufferGeometry();
}
const updateLines = () => {
const points = pointsRef.current;
const ceilingY = levelYRef.current + CEILING_HEIGHT;
// Reset state on level change
pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: getLevelY() });
if (points.length === 0) {
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
return;
}
// Build main line points
const linePoints: Vector3[] = points.map(
([x, z]) => new Vector3(x, ceilingY, z)
);
// Add cursor point
const lastPoint = points[points.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], ceilingY, snapped[1]));
}
}
// Update main line geometry
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose();
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
mainLineRef.current.visible = true;
} else {
mainLineRef.current.visible = false;
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0];
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
const closingPoints = [
new Vector3(snapped[0], ceilingY, snapped[1]),
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
];
closingLineRef.current.geometry.dispose();
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
closingLineRef.current.visible = true;
}
} else {
closingLineRef.current.visible = false;
}
};
const updatePreview = () => {
const points = pointsRef.current;
const lastPoint = points[points.length - 1];
let cursorPt: [number, number] | null = null;
if (lastPoint) {
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
} else if (points.length === 0) {
cursorPt = cursorPosition;
}
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
updateLines();
};
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return;
// Snap to 0.5 grid
const gridX = Math.round(event.position[0] * 2) / 2;
const gridZ = Math.round(event.position[2] * 2) / 2;
cursorPosition = [gridX, gridZ];
levelYRef.current = event.position[1];
const ceilingY = event.position[1] + CEILING_HEIGHT;
const gridY = event.position[1] + GRID_OFFSET;
// If we have points, snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
cursorRef.current.position.set(snapped[0], ceilingY, snapped[1]);
// Also update grid-level cursor
if (gridCursorRef.current) {
gridCursorRef.current.position.set(snapped[0], gridY, snapped[1]);
}
} else {
cursorRef.current.position.set(gridX, ceilingY, gridZ);
if (gridCursorRef.current) {
gridCursorRef.current.position.set(gridX, gridY, gridZ);
}
}
updatePreview();
};
const onGridClick = (event: GridEvent) => {
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return;
const gridX = Math.round(event.position[0] * 2) / 2;
const gridZ = Math.round(event.position[2] * 2) / 2;
let clickPoint: [number, number] = [gridX, gridZ];
// Use the cursor position tracked by useFrame (matches what user sees)
let clickPoint: [number, number] = [...cursorPositionRef.current];
// Snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
@@ -230,16 +275,17 @@ export const CeilingTool: React.FC = () => {
// Reset state
pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
if (mainLineRef.current) mainLineRef.current.visible = false;
if (closingLineRef.current) closingLineRef.current.visible = false;
// Deactivate tool
setTool(null);
} else {
// Add point to polygon
pointsRef.current = [...pointsRef.current, clickPoint];
updatePreview();
lastDisplayRef.current = null; // Force preview update on next frame
}
};
@@ -252,22 +298,20 @@ export const CeilingTool: React.FC = () => {
// Reset state
pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
if (mainLineRef.current) mainLineRef.current.visible = false;
if (closingLineRef.current) closingLineRef.current.visible = false;
// Deactivate tool
setTool(null);
}
};
// Subscribe to events
emitter.on("grid:move", onGridMove);
emitter.on("grid:click", onGridClick);
emitter.on("grid:double-click", onGridDoubleClick);
return () => {
emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick);
emitter.off("grid:double-click", onGridDoubleClick);
+155 -107
View File
@@ -1,10 +1,12 @@
import { emitter, type GridEvent, useScene, SlabNode, type LevelNode } from "@pascal-app/core";
import { emitter, type GridEvent, useScene, SlabNode, type LevelNode, sceneRegistry } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useFrame } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react";
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three";
import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three";
import useEditor from "@/store/use-editor";
const Y_OFFSET = 0.02; // Small offset above floor level
const UP = new Vector3(0, 1, 0);
/**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
@@ -86,11 +88,18 @@ export const SlabTool: React.FC = () => {
const mainLineRef = useRef<Line>(null!);
const closingLineRef = useRef<Line>(null!);
const pointsRef = useRef<Array<[number, number]>>([]);
const levelYRef = useRef(0); // Track current level Y position
const levelYRef = useRef(0);
const cursorPositionRef = useRef<[number, number]>([0, 0]);
const lastDisplayRef = useRef<{ x: number; z: number } | null>(null);
const currentLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection);
const setTool = useEditor((state) => state.setTool);
// Reusable objects for raycasting (created once, avoid per-frame allocations)
const raycaster = useMemo(() => new Raycaster(), []);
const levelPlane = useMemo(() => new Plane(UP, 0), []);
const hitPoint = useMemo(() => new Vector3(), []);
// Preview state for reactive rendering (for shape and point markers)
const [preview, setPreview] = useState<PreviewState>({
points: [],
@@ -98,108 +107,148 @@ export const SlabTool: React.FC = () => {
levelY: 0,
});
// Resolve the current level's Y position from scene registry or node data
const getLevelY = (): number => {
if (!currentLevelId) return 0;
const levelMesh = sceneRegistry.nodes.get(currentLevelId);
if (levelMesh) return levelMesh.position.y;
const levelNode = useScene.getState().nodes[currentLevelId];
if (levelNode && 'level' in levelNode) {
const levelMode = useViewer.getState().levelMode;
const LEVEL_HEIGHT = 2.5;
const EXPLODED_GAP = 5;
return ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0));
}
return 0;
};
// Imperatively update line geometries (called from useFrame)
const updateLines = () => {
if (!mainLineRef.current || !closingLineRef.current) return;
const points = pointsRef.current;
const cursorPosition = cursorPositionRef.current;
const y = levelYRef.current + Y_OFFSET;
if (points.length === 0) {
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
return;
}
// Build main line points
const linePoints: Vector3[] = points.map(
([x, z]) => new Vector3(x, y, z)
);
// Add cursor point
const lastPoint = points[points.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], y, snapped[1]));
}
}
// Update main line geometry
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose();
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
mainLineRef.current.visible = true;
} else {
mainLineRef.current.visible = false;
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0];
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
const closingPoints = [
new Vector3(snapped[0], y, snapped[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
];
closingLineRef.current.geometry.dispose();
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
closingLineRef.current.visible = true;
}
} else {
closingLineRef.current.visible = false;
}
};
// Per-frame cursor positioning via direct raycasting onto the level plane.
// This bypasses R3F's event propagation (grid:move), ensuring the cursor
// always tracks the pointer regardless of tool activation/deactivation state.
useFrame((state) => {
if (!cursorRef.current) return;
// Sync level Y from scene registry each frame
levelYRef.current = getLevelY();
// Raycast from camera through pointer onto horizontal plane at level Y
raycaster.setFromCamera(state.pointer, state.camera);
levelPlane.constant = -levelYRef.current;
const hit = raycaster.ray.intersectPlane(levelPlane, hitPoint);
if (!hit) return;
// Snap to 0.5m grid
const gridX = Math.round(hit.x * 2) / 2;
const gridZ = Math.round(hit.z * 2) / 2;
cursorPositionRef.current = [gridX, gridZ];
// Apply axis snapping from last placed point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
let displayPoint: [number, number];
if (lastPoint) {
displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current);
} else {
displayPoint = [gridX, gridZ];
}
// Update cursor mesh position imperatively
cursorRef.current.position.set(displayPoint[0], levelYRef.current, displayPoint[1]);
// Update line geometries imperatively
updateLines();
// Only trigger React state update when snapped display position changes
if (
!lastDisplayRef.current ||
lastDisplayRef.current.x !== displayPoint[0] ||
lastDisplayRef.current.z !== displayPoint[1]
) {
lastDisplayRef.current = { x: displayPoint[0], z: displayPoint[1] };
setPreview({
points: [...pointsRef.current],
cursorPoint: displayPoint,
levelY: levelYRef.current,
});
}
});
// Click and double-click handlers for point placement
useEffect(() => {
if (!currentLevelId) return;
let cursorPosition: [number, number] = [0, 0];
// Initialize line geometries
mainLineRef.current.geometry = new BufferGeometry();
closingLineRef.current.geometry = new BufferGeometry();
if (mainLineRef.current) {
mainLineRef.current.geometry = new BufferGeometry();
}
if (closingLineRef.current) {
closingLineRef.current.geometry = new BufferGeometry();
}
const updateLines = () => {
const points = pointsRef.current;
const y = levelYRef.current + Y_OFFSET;
// Reset state on level change
pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: getLevelY() });
if (points.length === 0) {
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
return;
}
// Build main line points
const linePoints: Vector3[] = points.map(
([x, z]) => new Vector3(x, y, z)
);
// Add cursor point
const lastPoint = points[points.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], y, snapped[1]));
}
}
// Update main line geometry
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose();
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
mainLineRef.current.visible = true;
} else {
mainLineRef.current.visible = false;
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0];
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
const closingPoints = [
new Vector3(snapped[0], y, snapped[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
];
closingLineRef.current.geometry.dispose();
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
closingLineRef.current.visible = true;
}
} else {
closingLineRef.current.visible = false;
}
};
const updatePreview = () => {
const points = pointsRef.current;
const lastPoint = points[points.length - 1];
let cursorPt: [number, number] | null = null;
if (lastPoint) {
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
} else if (points.length === 0) {
cursorPt = cursorPosition;
}
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
updateLines();
};
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return;
// Snap to 0.5 grid
const gridX = Math.round(event.position[0] * 2) / 2;
const gridZ = Math.round(event.position[2] * 2) / 2;
cursorPosition = [gridX, gridZ];
levelYRef.current = event.position[1];
// If we have points, snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]);
} else {
cursorRef.current.position.set(gridX, event.position[1], gridZ);
}
updatePreview();
};
const onGridClick = (event: GridEvent) => {
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return;
const gridX = Math.round(event.position[0] * 2) / 2;
const gridZ = Math.round(event.position[2] * 2) / 2;
let clickPoint: [number, number] = [gridX, gridZ];
// Use the cursor position tracked by useFrame (matches what user sees)
let clickPoint: [number, number] = [...cursorPositionRef.current];
// Snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
@@ -221,13 +270,14 @@ export const SlabTool: React.FC = () => {
// Reset state
pointsRef.current = [];
setPreview({ points: [], cursorPoint: null, levelY: 0 });
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
if (mainLineRef.current) mainLineRef.current.visible = false;
if (closingLineRef.current) closingLineRef.current.visible = false;
} else {
// Add point to polygon
pointsRef.current = [...pointsRef.current, clickPoint];
updatePreview();
lastDisplayRef.current = null; // Force preview update on next frame
}
};
@@ -242,19 +292,17 @@ export const SlabTool: React.FC = () => {
// Reset state
pointsRef.current = [];
setPreview({ points: [], cursorPoint: null, levelY: 0 });
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
if (mainLineRef.current) mainLineRef.current.visible = false;
if (closingLineRef.current) closingLineRef.current.visible = false;
}
};
// Subscribe to events
emitter.on("grid:move", onGridMove);
emitter.on("grid:click", onGridClick);
emitter.on("grid:double-click", onGridDoubleClick);
return () => {
emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick);
emitter.off("grid:double-click", onGridDoubleClick);