Skip to content

Edit Drags

Started: Feb 3, 2026 | Completed: Feb 4, 2026 | Status: done

Goal

Make selected geometry editable by dragging. only faces can be selected. drag only works on a corner or an edge of the selected face. editing is geometrically confined to the 2D plane of the selected face.


Work Remaining

  • confine ALL rotations to one axis -> the normal of the selected face
  • better edge/corner dragging: default to the selected SO
    • it now defaults to the root SO
    • rotate a child -> it changes size — removed recompute_max_bounds call from rotate_object in Events_3D.ts. To restore: after quat.normalize(obj.so.orientation, obj.so.orientation); add if (obj.so.fixed && obj.parent) { orientation.recompute_max_bounds(obj.so); }
  • red dots show for ENABLED drag corners, should be blue
  • ability to drag the current SO
    • click on a face and drag it
    • ignore drag when applied to the root SO
    • constrain movement within the plane of the forward face OF THE PARENT SO
    • movement does not follow mouse
      • need to project mouse movement (2d vector) onto this plane (different vector)
      • compute the new x,y,z
  • stairs occlusion shape should shrink to fit stairs

Our Work

Understand current state

  • How does rotation drag work now? — e3.set_drag_handler in Setup.ts always calls rotate_object
  • What's the relationship between screen delta and world delta? — camera at (0,0,5), right=(1,0,0), up=(0,1,0)
  • Where do the cube vertices live? — O_Scene.vertices, but both cubes shared same array (fixed)

Design

  • Should edit drag modify O_Scene vertices directly, or go through SO? — through SO (move_vertex, move_vertices)
  • How to detect "drag on selection" vs "drag to rotate"? — edit_selection returns true if selection exists
  • What happens to inner cube when outer cube's corner moves? — nothing, they have separate vertex arrays now

Implement

  • Clone vertices on Scene.create (so each object has its own)
  • Add Point3.clone() method
  • Add move_vertex, move_vertices, edge_vertices, face_vertices to Smart_Object
  • Add screen_to_world, edit_selection to Events_3D
  • Route drag: selection ? edit : rotate (Setup.ts)
  • eliminate selection of edge or corner
  • click-drag only applies to an edge or a corner
  • drag is geometrically confined to the 2D plane of the selected face

Test manually

  • Click corner, drag — resizes 2 dimensions
  • Click edge, drag — resizes 1 dimension
  • Click face interior — selects face (no drag)
  • Click empty, drag — rotates as before
  • Hover disabled during drag/rotation

Polish

  • Visual feedback during drag

Artifacts

  • src/lib/ts/runtime/Smart_Object.ts — bounds-based geometry (x/y/z_min/max), derived vertices, face_normal, corner_in_face, edge_in_face, vertex_bound, edge_bound, axis_vector
  • src/lib/ts/signals/Events_3D.ts — ray-plane intersection, edit_selection, drag_target tracking, hover clearing
  • src/lib/ts/render/Setup.ts — route drag by selection
  • src/lib/md/builds.md — build 7 entry

Key Decisions

  • Bounds-based geometry: SO stores 6 bounds instead of 8 vertices. Simpler, supports negative dimensions.
  • Vertices derived: get vertices() computes from bounds using Math.min/max for consistent topology.
  • Ray-plane projection: Mouse positions projected onto face plane to get world-space delta.
  • Bound ownership: vertex_bound() maps vertex index to named bound it controls (for drag application).

Invisible Root

turn root into a pure container — invisible by default, non-interactive in 3D, selectable via panel or background click.

decisions

  • no auto-sizing. tried it, hated it. root keeps its user-set dimensions.
  • manual "fit" button in the actions row. shrink_to_fit: snapshot children's absolute bounds, resize parent to their bounding box, normalize children so offsets stay non-negative.
  • fit-normalize on startup if any SO has negative start/end/length (unless root is a repeater).
  • root is selectable via the parts hierarchy and via background click. attributes table shows, angles table hidden.
  • root visibility persists. serialize always writes visible. Engine only forces visible = false for brand new scenes.
  • background click selects root. second background click deselects.

implemented

phase 1: root invisible (rendering)

  • Engine.tsif (!saved) root_so.visible = false (new scenes only)
  • Engine.ts — startup fit-normalize when negatives detected (skip if root is repeater)
  • R_Dimensions.ts — skip root: if (!obj.parent) continue
  • Smart_Object.ts — serialize always writes visible (not just when false)

phase 2: root non-interactive in 3D (hit testing)

  • Hits_3D.ts — face hit loop skips root: if (!so.scene.parent) continue
  • Events_3D.ts — background click selects root (or deselects if root already selected)

phase 3: back face guidance

  • Hits_3D.tsback_most_face(so): front_most_face(so) ^ 1
  • Drag.ts — when parent is root, use back_most_face instead of front_most_face

phase 4: fit button

  • Engine.tsshrink_to_fit(): snapshot children's absolute bounds, compute bounding box, resize parent, normalize children offsets. disabled for repeaters.
  • D_Selected_Part.svelte — "fit" button in actions row, disabled when no children or is repeater.

phase 5: details panel

  • D_Attributes.svelte — angles table wrapped in {#if !is_root} (hidden for root)
  • D_Selected_Part.svelte — deselected root shows empty panel; disabled buttons use {#if} not disabled; needs_fit derived state for fit button visibility

phase 6: root_so source of truth

  • Scenes.tsroot_so: Smart_Object | null = null (plain property, SOT)
  • Stores.ts — removed w_root_so writable store and root_so getter entirely
  • Engine.ts, Render.ts, R_Grid.ts, Events_3D.ts, Graph.svelte — all read scenes.root_so

phase 7: HMR fix

  • Events.tsprivate wired = false guard prevents double document listener registration on HMR re-mount

contextual selectability

when a saved file is inserted, its root becomes a child. insert_child_from_text sets parent, so !so.scene.parent checks already treat it as non-root. SO stays invisible in both contexts. no code changes needed.

checklist of work on invisible root

  • work
    • root invisible by default (new scenes only), geometry retained for grid/constraints
    • root non-interactive in 3D (raycaster skips), selectable via parts list or background click
    • back face guidance during child drag
    • manual "fit" button (snapshot children bounds, resize parent, normalize offsets)
    • startup fit-normalize when negatives detected (skip if repeater)
    • background click: first selects root, second deselects
    • moved root_so SOT from Stores to Scenes (plain property, not a store)
    • HMR double-registration fix (Events.ts wired guard)

Shadows

  • back-face grid for manual testing of edit and shrink to fit
  • faint solid grid on back-facing root faces (R_Grid.ts render_back_grid)
  • fixed render_grid to use root_so.scene directly (invisible root broke objects.find)
  • grid spacing must NOT auto adjust with tumble (it still does)
  • selected SO needs a visible "shadow" rectangle projected 2D onto each back grid face
    • must be able to project a rotated SO
    • whisper -> 0.12 fill, 0.5 stroke
    • unrelocate the red and blue selection dots back to the frontmost SO face
    • rotated SOs cast tilted shadows — can't just use axis-aligned bounds. grab the SO's 8 world-space vertices, flatten each one onto the back face (like shining a light straight through). no clipping — if the child overshoots the root wall mid-drag, the shadow should show that. project to screen, fill the convex hull with a whisper of accent color (rgba(accent, 0.06)), thin stroke at 0.15. drawn inside render_back_grid's per-face loop, right after grid lines. skip if the root or nothing is selected.
  • add a slider for grid opacity in preferences details

proposal — shadow projection

all in R_Grid.ts. no other files change.

once per frame (before face loop): get the selected SO via hits_3d.selection?.so. skip if null or root. compute child_to_root = inv(root_world) * child_world — transforms child vertices into root's local space where face planes are axis-aligned. transform all 8 vertices from sel_so.vertices through this matrix. extract accent RGB via parseToRgba from color2k.

per back-facing face (after grid lines): copy each root-local vertex, set its fixed-axis component to fixed_val — flattens it onto the face plane. project to screen via host.project_vertex(flattened, root_world). skip behind-camera points (w < 0). Graham scan convex hull on the screen points. fill rgba(r,g,b,0.06), stroke rgba(r,g,b,0.15) at 1px.

new helper: convex_hull_2d — Graham scan, lives in the helpers section alongside axis_bounds and grid_spacing. ~15 lines. only used here.

why child_to_root: can't just flatten raw bounds — a rotated child's corners aren't where its bounds say. inverting root's world matrix and multiplying by child's gives the actual corner positions in root-local space, where flattening one axis is geometrically meaningful.