Skip to content

BoxControls ​

BoxControls moves, resizes, turns and mirrors one box volume with the pointer, either an AreaBox or a MarqueeBox. As a THREE.Controls subclass, it shares the OrbitControls lifecycle and enabled contract.

ts
import { AreaBox, BoxControls } from "@jolly-pixel/three";

const controls = new BoxControls<AreaBox>(camera, renderer.domElement, {
  snap: 1,
  moveAxes: "xz",
  resizeAxes: "xz"
});

controls.attach(area);
controls.addEventListener("change", ({ min, size }) => {
  persist(min, size);
});

Gestures ​

GestureEffect
Drag the volumeMoves the area on the ground plane.
Ctrl (⌘ on macOS) + drag the volumeMoves the area vertically (requires moveAxes: "xyz").
Drag a face arrowMoves that face alone; the opposite face stays fixed.
Click one half of the corner arcRequests a quarter turn in the direction of that half's arrowhead (requires rotateAxes: "y").
Drag the corner arcRequests one quarter turn each time the pointer sweeps past the next 45° around the pivot.
Click a mirror chipRequests a mirror in place along the chip's axis (requires flipAxes).
Alt during a gestureSuspends snapping while held (unless snapBypass is false).

The vertical modifier remains live until the first movement, then the drag plane locks to prevent jumps. Either Ctrl or ⌘ counts, on every platform. Shift stays free for fly cameras that descend with it.

The element's cursor becomes grab over the volume or an arrow and grabbing during a gesture, then returns to its previous value.

Each of the six constant-screen-size arrows has a thin shaft, cone head and enlarged invisible picker. Pickers may cover small areas, and arrows win hit tests over the volume. Reduce handleSize for areas one or two cells wide.

The corner arc sits on the top corner facing the camera. Hovering it shows the pivot as a vertical axis; dragging it replaces it with a dial whose wedge follows the pointer. Mirror chips sit beside the camera-facing face of each allowed axis, beyond the resize arrow when that axis also resizes. Hovering a chip shows the mirror plane through the box center, so the volume keeps its place. A chip mirrors on release only when the pointer is still over it.

Constructor ​

ts
new BoxControls(
  camera: THREE.Camera,
  domElement?: HTMLElement | null,
  options?: BoxControlsOptions
)

A non-null domElement connects immediately, while null defers connection until connect().

ts
interface BoxControlsOptions {
  snap?: number | THREE.Vector3Like | null;
  snapBypass?: boolean;
  minSize?: THREE.Vector3Like | null;
  bounds?: THREE.Box3 | null;
  moveAxes?: BoxAxisPolicy;
  resizeAxes?: BoxResizePolicy;
  rotateAxes?: BoxRotatePolicy;
  flipAxes?: BoxFlipPolicy;
  pivot?: THREE.Vector3 | null;
  handleSize?: number;
}
OptionDefaultDescription
snap1Grid step. null disables snapping.
snapBypasstrueLets Alt suspend snapping during a gesture. false keeps grid-bound content, such as voxels, on the grid.
minSizeone snap step per axisSmallest extent a resize may reach, per axis.
boundsnullParent-space volume used to clamp moves and dragged faces.
moveAxes"xz"Axes a move may affect.
resizeAxes"xz"Axes a resize may affect; arrows of excluded axes are hidden. "none" disables resizing.
rotateAxes"none""y" shows the corner arc for quarter turns around the vertical axis.
flipAxes"none"Axes that get a mirror chip.
pivotnullParent-space point that quarter turns go through. null uses the box center. Mirrors always go through the box center.
handleSize0.035Arrow size as a fraction of the viewport height.

All options except handleSize are live properties. Changing resizeAxes, rotateAxes or flipAxes updates the handles immediately. pivot is read on every press and frame, so it can be mutated in place; changing it does not end a gesture.

Moving an area larger than the bounds preserves its size and pins its min corner to the lower bound. During resize, minSize wins when both constraints conflict.

Snapping ​

Snapping is absolute. Values discard any sub-step offset and land on step multiples, so x = 0.3 snaps on first touch.

Moves snap the min corner. Resizes snap the dragged face and preserve the opposite face. Crossing it clamps at minSize without inverting the box.

Axis policies ​

ts
type BoxAxisPolicy = "xz" | "xyz";
type BoxResizePolicy = BoxAxisPolicy | "none";
type BoxRotatePolicy = "y" | "none";
type BoxFlipPolicy = BoxAxisPolicy | "none";

"xz" keeps gestures on the ground plane, while "xyz" enables the vertical axis. The policies are independent. Tile editors with fixed height can keep both at "xz". Level editors placing trigger volumes can set both to "xyz". resizeAxes also accepts "none", which hides every arrow for boxes that only move, such as a placement preview.

Properties ​

box ​

ts
get box(): TBox | null

The attached box, or null. TBox is the class type parameter and defaults to BoxVolume, the base of AreaBox and MarqueeBox.

dragging ​

ts
get dragging(): boolean

true between start and end. Use it to skip host selection after the controls claim a drag.

camera ​

ts
get camera(): THREE.Camera

Alias of the inherited object.

Methods ​

attach() / detach() ​

ts
attach(box: TBox, options?: { from?: PointerEvent }): boolean
detach(): void

attach() parents the arrows into box and sets its state to "active". detach() reverses both.

Controls test only the attached box, against its volume rather than a mesh, so a box without a fill can be dragged. A press from a camera inside the volume claims nothing. Pass the selecting pointerdown as from to claim the gesture before camera controls receive it. The return value reports whether it was claimed.

ts
// Capture phase: the host decides what is selected before the area controls
// and the camera controls see the press.
canvas.addEventListener("pointerdown", (event) => {
  if (controls.isOverHandle(event)) {
    // The press belongs to the gizmo, not to whatever lies behind it.
    return;
  }

  const area = pick(event);
  if (area === null) {
    controls.detach();
  }
  else {
    controls.attach(area, { from: event });
  }
}, true);

The host chooses the area using its own layers, visibility and identity rules. Calling detach() during a gesture ends it and emits end.

cancel() ​

ts
cancel(): void

Ends the active gesture and puts the area back where it started; a moved or resized area fires one more change for the restore. Escape does the same while a gesture runs. Turns and mirrors already requested are not undone, and a cancelled click on an arc or chip requests nothing.

isOverHandle() ​

ts
isOverHandle(event: PointerEvent): boolean

Checks whether event hits an attached resize arrow, rotate arc or mirror chip. Call it before host picking because arrows lie outside the area and raycast through other geometry. Otherwise the host may select an object behind the arrow and interrupt the resize.

connect() / disconnect() / dispose() ​

ts
connect(element: HTMLElement): void
disconnect(): void
dispose(): void

disconnect() ends the gesture and removes listeners. dispose() also detaches and releases the arrow geometry, which is created once and reparented on attach().

Events ​

EventPayloadFired
start{ mode, axis }A gesture began.
change{ mode, axis, min, size }The area moved or resized.
rotate{ axis: "y", turns }A quarter turn was requested; turns is 1 or -1.
flip{ axis }A mirror along axis was requested.
end{ mode, axis, min, size, changed, cancelled }The gesture ended, was cancelled or was interrupted.

mode is "move", "resize", "rotate" or "flip". axis is the resized, turned or mirrored axis, or null for a move. min and size are safe-to-keep copies. On end, changed tells whether the gesture emitted a change that no cancel undid, and cancelled whether Escape or cancel() ended it.

change is deduplicated, so snapped drags emit at most once per grid step. Persist change for live updates. The end event confirms the final value, while free drags (snap: null or Alt) normally emit on every pointer event.

Suspending the camera ​

Area controls do not modify other controls. Suspend them with start and end:

ts
controls.addEventListener("start", () => {
  orbit.enabled = false;
});
controls.addEventListener("end", () => {
  orbit.enabled = true;
});

Applying changes ​

The controls mutate the area during a drag. Owners may overwrite position and size mid-drag, including after a remote conflict. The next pointer event uses the corrected state in parent space, avoiding transformed-parent offsets.

Turning and mirroring ​

The controls never turn or mirror the box: an axis-aligned min corner and size cannot hold a partial turn, and where a quarter turn lands depends on the host's own pivot rules. rotate and flip are requests. The host applies them to its model, then writes the new position and size back, as it may do mid-drag.

turns: 1 is a positive rotation around +Y (counter-clockwise seen from above). During a drag, rotate fires once per quarter step as the pointer passes 45° plus a small dead band from the last step, and sweeping back fires the opposite turn. A release within 4 pixels of the press counts as a click; a longer drag that never reaches a step requests nothing. When the camera looks across the vertical axis, horizontal screen motion drives the turn instead.

ts
const controls = new BoxControls(camera, canvas, {
  resizeAxes: "none",
  rotateAxes: "y",
  flipAxes: "xz",
  pivot: new THREE.Vector3()
});

controls.addEventListener("rotate", ({ turns }) => {
  placement.turn(turns);
  syncBox(placement);
});
controls.addEventListener("flip", ({ axis }) => {
  placement.mirror(axis);
  syncBox(placement);
});