/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Selection handler functions extracted from useMouseControls. * Handles click/double-click selection and context menu interactions. * Pure functions that operate on a MouseHandlerContext — no React dependency. */ import type { MouseHandlerContext } from './mouseHandlerTypes.js'; import { useViewerStore } from '@/store'; import { fromGlobalIdFromModels, toGlobalIdFromModels } from '@/store/globalId'; import { pointInPolygon } from '@/lib/polygon-clip'; import { toast } from '@/components/ui/toast'; /** * Handle click event for selection (single click and double click). * Manages click timing for double-click detection and Ctrl/Cmd multi-select. */ export async function handleSelectionClick(ctx: MouseHandlerContext, e: MouseEvent): Promise { const { canvas, renderer, mouseState } = ctx; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const tool = ctx.activeToolRef.current; // Skip selection if user was dragging (orbiting/panning) if (mouseState.didDrag) { return; } // Skip selection for pan/walk tools - they don't select if (tool === 'pan' || tool === 'walk') { return; } // Measure tool now uses drag interaction (see mousedown/mousemove/mouseup) if (tool === 'measure') { return; // Skip click handling for measure tool } // Section-tool face-pick (issue #243): clicking any visible face places // the clip plane through it. Intercept BEFORE the generic select path // so the click doesn't also flip the selection. // // Camera-aware orientation: we flip the picked normal if it faces away // from the camera, so the kept half-space is the one the user is looking // at by default (the most common expectation; if the cut goes the wrong // way the existing Flip button still works). This addresses the // CodeRabbit minor on PR #581 about face-pick not being camera-aware. if (tool === 'section' && ctx.sectionPickModeRef?.current) { const hit = renderer.raycastScene(x, y, { hiddenIds: ctx.hiddenEntitiesRef.current, isolatedIds: ctx.isolatedEntitiesRef.current, }); if (hit?.intersection) { const n = hit.intersection.normal; const p = hit.intersection.point; const cam = renderer.getCamera().getPosition(); // View vector = camera → hit. If `dot(view, normal) > 0` the normal // points away from the camera; invert so the cut keeps the side // facing the user. const vx = cam.x - p.x, vy = cam.y - p.y, vz = cam.z - p.z; const dot = vx * n.x + vy * n.y + vz * n.z; const sign = dot < 0 ? -1 : 1; const bounds = ctx.modelBoundsRef?.current; ctx.setSectionPlaneFromFace?.( [sign * n.x, sign * n.y, sign * n.z], [p.x, p.y, p.z], bounds ? { min: [bounds.min.x, bounds.min.y, bounds.min.z], max: [bounds.max.x, bounds.max.y, bounds.max.z], } : undefined, ); } else { // Missed geometry — disarm so the user isn't stuck in pick mode // after an errant background click. ctx.setSectionPickMode?.(false); } return; } // Add-element tool — multi-click placement (start→end for walls/beams, // corner→opposite for slab rectangle, N+Enter for slab polygon, single // for columns). Uses magnetic snap so points lock to vertices/edges // when the cursor is near them — same UX as the measure tool. // Split tool — click on a wall / beam / column / member to cut // it at the cursor's projected position. Hover preview lives on // the store (splitHoverPoint / splitHoverDistance) and // SplitOverlay renders the SVG guide; this handler commits the // click by dispatching to the right action for the latched // target's element type. if (tool === 'split') { const state = useViewerStore.getState(); const targetModelId = state.splitTargetModelId; const targetExpressId = state.splitTargetExpressId; if (targetModelId === null || targetExpressId === null) { // Click missed any splittable element — no-op so the cursor // doesn't fight the user. The overlay's hint stays visible. return; } // Slab two-click flow takes precedence when we're mid-anchor. // The second click commits the cut line from anchor → cursor. // Raycast onto the SOURCE SLAB'S floor (not the global active // storey) so federated / non-active-storey splits land at the // right elevation. if (state.splitMode === 'first-anchor' && state.slabCutAnchor) { const slabFloorY = resolveSlabFloorY(targetModelId, targetExpressId); const cutPoint = raycastStoreyFloor(ctx, x, y, slabFloorY ?? undefined); if (!cutPoint) { toast.error("Couldn't read cut point"); return; } const cursorIfc = rendererPointToIfcStoreyLocal(cutPoint); const result = state.splitSlabByLine( targetModelId, targetExpressId, state.slabCutAnchor, [cursorIfc[0], cursorIfc[1]], ); if (!result.ok) { toast.error(`Couldn't split slab: ${result.reason}`); return; } state.clearSplitHover(); // Move selection to whichever half contains the click. Both // halves are valid polygons; the click landed at `cursorIfc`. const rightFp = state.readSlabFootprint(targetModelId, result.right.expressId); const inRight = rightFp ? pointInPolygon(rightFp.footprint, [cursorIfc[0], cursorIfc[1]]) : false; state.setSelectedEntityId(inRight ? result.right.globalId : result.left.globalId); toast.success('Slab split — Ctrl+Z to undo'); return; } // Single-click element split (wall / beam / column / member). // Wall first because most edits land on walls; if that returns // null we try the linear-element path. const distance = state.splitHoverDistance; if (distance !== null && distance > 0) { const wallTry = state.splitWallAtDistance(targetModelId, targetExpressId, distance); if (wallTry.ok) { state.clearSplitHover(); state.setSelectedEntityId(wallTry.right.globalId); // Mention opening reassignment in the toast only when it // happened — silence is preferable to "0 openings moved" // for a wall with no doors / windows. const op = wallTry.openings; const opSummary = op.toLeft + op.toRight > 0 ? ` (${op.toLeft + op.toRight} opening${op.toLeft + op.toRight === 1 ? '' : 's'} reassigned)` : ''; toast.success(`Wall split${opSummary} — Ctrl+Z to undo`); return; } const linearTry = state.splitLinearElementAtDistance( targetModelId, targetExpressId, distance, ); if (linearTry.ok) { state.clearSplitHover(); state.setSelectedEntityId(linearTry.right.globalId); toast.success('Element split — Ctrl+Z to undo'); return; } } // Fall through to the slab path: first click latches the // anchor, second click (handled above) commits. Anchor lands // on the source slab's storey floor (not the global active // storey) — `slabFootprint.storeyElevation` is already the // exact value we need, so pass it through directly. const slabFootprint = state.readSlabFootprint(targetModelId, targetExpressId); if (slabFootprint) { const anchorPoint = raycastStoreyFloor(ctx, x, y, slabFootprint.storeyElevation); if (!anchorPoint) { toast.error("Couldn't read anchor point"); return; } const anchorIfc = rendererPointToIfcStoreyLocal(anchorPoint); state.setSlabCutAnchor( [anchorIfc[0], anchorIfc[1]], slabFootprint.footprint, slabFootprint.storeyElevation, ); return; } toast.error("Couldn't split: not a splittable element"); return; } if (tool === 'addElement') { const currentLock = ctx.edgeLockStateRef.current; const result = renderer.raycastSceneMagnetic(x, y, { edge: currentLock.edge, meshExpressId: currentLock.meshExpressId, lockStrength: currentLock.lockStrength, }, { hiddenIds: ctx.hiddenEntitiesRef.current, isolatedIds: ctx.isolatedEntitiesRef.current, snapOptions: ctx.snapEnabledRef.current ? { snapToVertices: true, snapToEdges: true, snapToFaces: true, screenSnapRadius: 40, } : { snapToVertices: false, snapToEdges: false, snapToFaces: false, screenSnapRadius: 0, }, }); const point = result.snapTarget?.position ?? result.intersection?.point ?? raycastStoreyFloor(ctx, x, y); if (!point) return; // Smart-placement: if the click landed on (or snapped to) an // existing entity, infer the target storey from THAT entity's // spatial-hierarchy entry rather than the AddElement panel's // dropdown. Lets the user click on a wall on storey 3 to add // a beam there without first changing the storey selector. // Only kicks in when we actually have an entity under the // cursor — empty-space clicks fall back to the panel value. const hitExpressId = result.snapTarget?.expressId ?? result.intersection?.expressId ?? null; const inferredStorey = hitExpressId !== null ? inferStoreyForGlobalId(hitExpressId) : null; await handleAddElementDrop(point, inferredStorey ?? undefined); return; } // Annotate tool — drop a pin at the cursor's world point. // Raycasts the scene; if the click misses geometry the draft is // not opened (annotations are anchored to surface points by // design, not floating in space). if (tool === 'annotate') { const result = renderer.raycastScene(x, y, ctx.getPickOptions()); if (!result?.intersection) return; const { intersection } = result; const store = useViewerStore.getState(); // Federated models — resolve which model the hit globalId belongs // to so the annotation carries enough context to render its // popover header. Falls back to (null, expressId) when there's // only the legacy single-model state. const modelLookup = fromGlobalIdFromModels(store.models, intersection.expressId); const modelId = modelLookup?.modelId ?? null; const localExpressId = modelLookup?.expressId ?? intersection.expressId; store.beginDraft( { x: intersection.point.x, y: intersection.point.y, z: intersection.point.z }, localExpressId ?? null, modelId, ); return; } const now = Date.now(); const timeSinceLastClick = now - ctx.lastClickTimeRef.current; const clickPos = { x, y }; if (ctx.lastClickPosRef.current && timeSinceLastClick < 300 && Math.abs(clickPos.x - ctx.lastClickPosRef.current.x) < 5 && Math.abs(clickPos.y - ctx.lastClickPosRef.current.y) < 5) { const pickOptions = ctx.getPickOptions(); // Double-click - isolate element // Uses visibility filtering so only visible elements can be selected const pickResult = await renderer.pick(x, y, pickOptions); if (pickResult) { ctx.handlePickForSelection(pickResult); } ctx.lastClickTimeRef.current = 0; ctx.lastClickPosRef.current = null; } else { const pickOptions = ctx.getPickOptions(); // Single click - uses visibility filtering so only visible elements can be selected const pickResult = await renderer.pick(x, y, pickOptions); // Multi-selection with Ctrl/Cmd if (e.ctrlKey || e.metaKey) { if (pickResult) { ctx.toggleSelection(pickResult.expressId); } } else { ctx.handlePickForSelection(pickResult); } ctx.lastClickTimeRef.current = now; ctx.lastClickPosRef.current = clickPos; } } /** * Resolve the storey + model for a hit globalId so the AddElement * click handler can place the new entity in the SAME storey as the * existing element under the cursor. Returns null when the hit * doesn't resolve to any model's spatial-hierarchy entry — caller * falls back to the AddElement panel's storey selector. * * Federation-aware via `fromGlobalIdFromModels`. */ function inferStoreyForGlobalId( globalId: number, ): { modelId: string; storeyId: number } | null { const state = useViewerStore.getState(); const local = fromGlobalIdFromModels(state.models, globalId); if (!local) return null; const ds = state.models.get(local.modelId)?.ifcDataStore; const storeyId = ds?.spatialHierarchy?.elementToStorey.get(local.expressId); if (storeyId === undefined) return null; return { modelId: local.modelId, storeyId }; } /** * Find the first IfcBuildingStorey entity in the active model. Used as a * fallback when the user hasn't picked a target storey in the panel. */ function firstStoreyExpressId(modelId: string): number | null { const state = useViewerStore.getState(); const model = state.models.get(modelId); const ids = model?.ifcDataStore?.entityIndex.byType.get('IFCBUILDINGSTOREY'); return ids && ids.length > 0 ? ids[0] : null; } /** * Active model resolver — falls back through the same legacy chain * the rest of the viewer uses when a single model is loaded. */ function resolveActiveModelId(): string | null { const state = useViewerStore.getState(); if (state.activeModelId) return state.activeModelId; const first = state.models.keys().next(); return first.done ? null : first.value; } /** * Convert a renderer Y-up world point to IFC Z-up storey-local * coordinates with Z forced to the storey floor (0). Mirrors the * matrix in `packages/renderer/src/pipeline.ts`. Z is clamped so the * click landing on a vertical surface doesn't lift the element above * the floor — matches construction-tool placement intuition. Refine * via the Raw STEP tab if needed. */ export function rendererPointToIfcStoreyLocal(point: { x: number; y: number; z: number }): [number, number, number] { return [point.x, -point.z, 0]; } /** * Storey-floor ray-plane intersection — used as a fallback when the * scene raycast misses every mesh (so the user can place new elements * in empty space, not just on existing surfaces). The floor sits at * renderer Y = storey elevation. * * `storeyOverride` lets the caller supply an explicit floor Y * (renderer frame). Used by the Split tool so a slab on storey 3 * doesn't accidentally project clicks onto storey 0's floor when * the addElement / active-model state points elsewhere. Falls back * to `resolveStoreyFloorY()` (active-model lookup) when omitted. */ function raycastStoreyFloor( ctx: MouseHandlerContext, x: number, y: number, storeyOverride?: number, ): { x: number; y: number; z: number } | null { const camera = ctx.renderer.getCamera(); const canvas = ctx.renderer.getCanvas(); if (!camera || !canvas) return null; // x/y arrive in CSS space (handleSelectionClick subtracts the // bounding-rect origin). `unprojectToRay` expects drawing-buffer // coords, which differ from CSS by DPR. Convert both the cursor // and the canvas size so the ray is computed in the same space // `projectToScreen` writes to — otherwise pick drifts at DPR ≠ 1. const rect = canvas.getBoundingClientRect(); const sx = rect.width > 0 ? (x / rect.width) * canvas.width : x; const sy = rect.height > 0 ? (y / rect.height) * canvas.height : y; const ray = camera.unprojectToRay(sx, sy, canvas.width, canvas.height); if (!ray) return null; const planeY = storeyOverride ?? resolveStoreyFloorY(); // Looking down typically means D.y < 0; reject parallel / near-parallel // cases so we don't hand back a wildly extrapolated intersection. const dy = ray.direction.y; if (Math.abs(dy) < 1e-6) return null; const t = (planeY - ray.origin.y) / dy; if (!Number.isFinite(t) || t <= 0) return null; return { x: ray.origin.x + ray.direction.x * t, y: planeY, z: ray.origin.z + ray.direction.z * t, }; } /** * Helper: resolve a slab's storey elevation in renderer-frame Y so * callers can pass it to `raycastStoreyFloor`. Read from the * model's spatialHierarchy (matches the rest of the split flow's * elevation lookups). Returns null when the slab isn't contained * in a storey we can resolve. */ function resolveSlabFloorY(modelId: string, expressId: number): number | null { const state = useViewerStore.getState(); const ds = state.models.get(modelId)?.ifcDataStore; if (!ds) return null; const storeyId = ds.spatialHierarchy?.elementToStorey.get(expressId); if (storeyId === undefined) return null; const elev = ds.spatialHierarchy?.storeyElevations?.get(storeyId); return typeof elev === 'number' ? elev : null; } /** * Resolve the renderer Y of the currently selected (or first * available) storey's floor. Falls back to 0 when nothing is loaded. */ function resolveStoreyFloorY(): number { const state = useViewerStore.getState(); const modelId = state.addElementModelId ?? state.activeModelId; if (!modelId) return 0; const model = state.models.get(modelId); const ds = model?.ifcDataStore; if (!ds) return 0; const storeyId = state.addElementStoreyId ?? firstStoreyExpressId(modelId); if (storeyId === null) return 0; return ds.spatialHierarchy?.storeyElevations?.get(storeyId) ?? 0; } /** * Update the live hover preview for the add-element tool. Runs the * same magnetic raycast as the click handler and keeps `hoverPoint` * in sync with whatever the next click would place. Used by the * 3D-overlay preview so the user sees the in-progress edge / rectangle * / polygon segment as they move the cursor. * * Returns true when handled so the mouse-controls hook can early-out * before falling through to the generic hover-tooltip path. */ export function handleAddElementHover(ctx: MouseHandlerContext, x: number, y: number): boolean { const { renderer } = ctx; if (!ctx.measureRaycastPendingRef.current) { ctx.measureRaycastPendingRef.current = true; ctx.measureRaycastFrameRef.current = requestAnimationFrame(() => { ctx.measureRaycastPendingRef.current = false; ctx.measureRaycastFrameRef.current = null; const currentLock = ctx.edgeLockStateRef.current; const result = renderer.raycastSceneMagnetic(x, y, { edge: currentLock.edge, meshExpressId: currentLock.meshExpressId, lockStrength: currentLock.lockStrength, }, { hiddenIds: ctx.hiddenEntitiesRef.current, isolatedIds: ctx.isolatedEntitiesRef.current, snapOptions: ctx.snapEnabledRef.current ? { snapToVertices: true, snapToEdges: true, snapToFaces: true, screenSnapRadius: 40, } : { snapToVertices: false, snapToEdges: false, snapToFaces: false, screenSnapRadius: 0, }, }); const point = result.snapTarget?.position ?? result.intersection?.point ?? raycastStoreyFloor(ctx, x, y); const store = useViewerStore.getState(); store.setAddElementHoverPoint(point ? { x: point.x, y: point.y, z: point.z } : null); // Mirror measure's snap-viz behaviour so vertex/edge/face indicators // appear under the cursor with the same UX shape. ctx.setSnapTarget(result.snapTarget ?? null); if (result.snapTarget) { if (result.edgeLock.shouldRelease) { ctx.clearEdgeLock(); } else if (result.edgeLock.shouldLock && result.edgeLock.edge) { ctx.setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId!, result.edgeLock.edgeT); } } else { ctx.clearEdgeLock(); } }); } return true; } /** * Live hover preview for the Split tool. SCOPED TO THE CURRENT * SPLIT TARGET — the slice's `splitTargetModelId` / * `splitTargetExpressId` are set when the user enters Split mode * (via Geometry-card Split button or K shortcut), and they never * change as the cursor moves over other elements. Hovering over a * different wall does NOT switch target; the user must exit Split * (Esc), re-select, and re-enter. * * This is the v2 model — the previous "free-roam, hover anything, * latch new target each frame" approach surfaced way too many * actionable elements at once and made it unclear what would be * cut. Selection-bound matches the rest of the edit-mode UX * (gizmo, geometry card, etc.). * * The cursor still drives the cut POINT — we ray-cast to get a * world point, project that onto the target's axis (linear) or * use cursor XY (slab), and push the result into the slice for * the SplitOverlay to render. */ export function handleSplitHover(ctx: MouseHandlerContext, x: number, y: number): boolean { const { renderer } = ctx; if (!ctx.measureRaycastPendingRef.current) { ctx.measureRaycastPendingRef.current = true; ctx.measureRaycastFrameRef.current = requestAnimationFrame(() => { ctx.measureRaycastPendingRef.current = false; ctx.measureRaycastFrameRef.current = null; const store = useViewerStore.getState(); const targetModelId = store.splitTargetModelId; const targetExpressId = store.splitTargetExpressId; if (targetModelId === null || targetExpressId === null) { // Split engaged with no target — clear any stale hover. if (store.splitMode === 'aiming') store.clearSplitHover(); return; } // Ray-cast to get a world point under the cursor. We don't // care which entity it hit — we use the world point and // project it onto the TARGET's axis. Falls back to the // storey floor so the user can aim at empty space and still // get a sensible cut. const result = renderer.raycastScene(x, y, { hiddenIds: ctx.hiddenEntitiesRef.current, isolatedIds: ctx.isolatedEntitiesRef.current, }); // Fallback raycast uses the TARGET's storey floor (not the // global active storey) so the cursor projection stays in // the same Y plane as the element being split. const targetFloorY = resolveSlabFloorY(targetModelId, targetExpressId) ?? undefined; const worldPoint = result?.intersection?.point ?? raycastStoreyFloor(ctx, x, y, targetFloorY); if (!worldPoint) { if (store.splitMode === 'aiming') store.clearSplitHover(); return; } const cursorIfc = rendererPointToIfcStoreyLocal(worldPoint); // Project onto the target. Try wall (1D), then linear (1D), // then slab (2D — uses the cursor XY directly as a candidate // cut endpoint). const projection = store.readWallSplitProjection(targetModelId, targetExpressId, cursorIfc) ?? store.readLinearElementSplitProjection(targetModelId, targetExpressId, cursorIfc); if (projection) { const model = store.models.get(targetModelId); const storeyId = model?.ifcDataStore?.spatialHierarchy?.elementToStorey.get(targetExpressId); const elevation = (storeyId !== undefined ? model?.ifcDataStore?.spatialHierarchy?.storeyElevations?.get(storeyId) : undefined) ?? 0; const [px, py, pz] = projection.cutPoint; const cutRendererFrame: [number, number, number] = [px, pz + elevation, -py]; store.setSplitHover( cutRendererFrame, projection.distance, projection.length, projection.cutPoint, projection.axis, ); return; } // Slab path — the overlay outlines the polygon + ghost cut // line; the cursor's IFC XY is the candidate endpoint. const slabFootprint = store.readSlabFootprint(targetModelId, targetExpressId); if (slabFootprint) { const [cx, cy] = cursorIfc; const cursorRendererFrame: [number, number, number] = [ cx, slabFootprint.storeyElevation, -cy, ]; store.setSplitHover(cursorRendererFrame, 0, 0, [cx, cy, 0], null); return; } // Target isn't a splittable shape (rare — gets latched by // the K shortcut / Split button which already checks). Clear. if (store.splitMode === 'aiming') store.clearSplitHover(); }); } return true; } /** * Resolve the active model + storey + a snap-aware world point. * Surfaces the same toast errors all add-element entry points share. * * `override` lets the caller force a specific (model, storey) pair — * used by smart placement so clicking on an existing element places * the new entity in that element's storey/model rather than the * AddElement panel's currently-selected one. Falls back through the * panel's selector (`addElementStoreyId`) and then the first storey * of the active model when no override is supplied. */ function resolveAddElementContext( override?: { modelId: string; storeyId: number }, ): { modelId: string; storeyId: number } | null { if (override) return override; const state = useViewerStore.getState(); const modelId = state.addElementModelId ?? resolveActiveModelId(); if (!modelId) { toast.error("Couldn't add element: no model loaded"); return null; } const storeyId = state.addElementStoreyId ?? firstStoreyExpressId(modelId); if (storeyId === null) { toast.error("Couldn't add element: model has no IfcBuildingStorey"); return null; } return { modelId, storeyId }; } /** Common post-place: pick the new entity's global id, toast, clear pending. */ function finishAddElement( result: { expressId: number } | { error: string }, modelId: string, label: string, ): void { const state = useViewerStore.getState(); if ('error' in result) { toast.error(`Couldn't add ${label.toLowerCase()}: ${result.error}`); return; } const globalId = toGlobalIdFromModels(state.models, modelId, result.expressId); state.setSelectedEntityId(globalId); state.clearAddElementPending(); toast.success(`${label} #${result.expressId} added — undo to remove`); } /** * Handle a click landing on the scene while the addElement tool is * active. Implements a per-type click state machine: * * - column: 1 click → place * - wall / beam: 1st click → start, 2nd click → end + place * - slab (rectangle): 1st click → corner, 2nd click → opposite + place * - slab (polygon): N clicks accumulate; Enter / double-click closes * (handled in the keyboard layer; this function only appends) */ async function handleAddElementDrop( point: { x: number; y: number; z: number }, storeyOverride?: { modelId: string; storeyId: number }, ): Promise { const ctx = resolveAddElementContext(storeyOverride); if (!ctx) return; const { modelId, storeyId } = ctx; const state = useViewerStore.getState(); const type = state.addElementType; // Single-click placements: column / door / window all drop on one click. if (type === 'column') { const ifc = rendererPointToIfcStoreyLocal(point); const p = state.addElementColumnParams; finishAddElement(state.addColumn(modelId, storeyId, { Position: ifc, Width: p.Width, Depth: p.Depth, Height: p.Height, }), modelId, 'Column'); return; } if (type === 'door') { const ifc = rendererPointToIfcStoreyLocal(point); const p = state.addElementDoorParams; finishAddElement(state.addDoor(modelId, storeyId, { Position: ifc, Width: p.Width, Height: p.Height, FrameThickness: p.FrameThickness, }), modelId, 'Door'); return; } if (type === 'window') { const ifc = rendererPointToIfcStoreyLocal(point); const p = state.addElementWindowParams; finishAddElement(state.addWindow(modelId, storeyId, { Position: ifc, Width: p.Width, Height: p.Height, FrameThickness: p.FrameThickness, }), modelId, 'Window'); return; } if (type === 'wall' || type === 'beam' || type === 'member') { const pending = state.addElementPendingPoints; if (pending.length === 0) { // Start point — store the renderer-frame point and wait for end. state.appendAddElementPendingPoint({ x: point.x, y: point.y, z: point.z }); return; } // End point — convert both points to IFC at dispatch time. const startIfc = rendererPointToIfcStoreyLocal(pending[0]); const endIfc = rendererPointToIfcStoreyLocal(point); if (type === 'wall') { const p = state.addElementWallParams; finishAddElement(state.addWall(modelId, storeyId, { Start: startIfc, End: endIfc, Thickness: p.Thickness, Height: p.Height, }), modelId, 'Wall'); } else if (type === 'beam') { const p = state.addElementBeamParams; finishAddElement(state.addBeam(modelId, storeyId, { Start: startIfc, End: endIfc, Width: p.Width, Height: p.Height, }), modelId, 'Beam'); } else { // member const p = state.addElementMemberParams; finishAddElement(state.addMember(modelId, storeyId, { Start: startIfc, End: endIfc, Width: p.Width, Height: p.Height, }), modelId, 'Member'); } return; } if (type === 'slab' || type === 'roof' || type === 'plate' || type === 'space') { if (state.addElementSlabMode === 'rectangle') { const pending = state.addElementPendingPoints; if (pending.length === 0) { state.appendAddElementPendingPoint({ x: point.x, y: point.y, z: point.z }); return; } const cornerIfc = rendererPointToIfcStoreyLocal(pending[0]); const oppositeIfc = rendererPointToIfcStoreyLocal(point); const minX = Math.min(cornerIfc[0], oppositeIfc[0]); const minY = Math.min(cornerIfc[1], oppositeIfc[1]); const width = Math.abs(oppositeIfc[0] - cornerIfc[0]); const depth = Math.abs(oppositeIfc[1] - cornerIfc[1]); if (width <= 0 || depth <= 0) { toast.error(`${capitalize(type)} corners must span a non-zero rectangle`); return; } const position: [number, number, number] = [minX, minY, 0]; switch (type) { case 'slab': { const p = state.addElementSlabParams; finishAddElement(state.addSlab(modelId, storeyId, { Position: position, Width: width, Depth: depth, Thickness: p.Thickness, }), modelId, 'Slab'); return; } case 'roof': { const p = state.addElementRoofParams; finishAddElement(state.addRoof(modelId, storeyId, { Position: position, Width: width, Depth: depth, Thickness: p.Thickness, }), modelId, 'Roof'); return; } case 'plate': { const p = state.addElementPlateParams; finishAddElement(state.addPlate(modelId, storeyId, { Position: position, Width: width, Depth: depth, Thickness: p.Thickness, }), modelId, 'Plate'); return; } case 'space': { const p = state.addElementSpaceParams; finishAddElement(state.addSpace(modelId, storeyId, { Position: position, Width: width, Depth: depth, Height: p.Height, }), modelId, 'Space'); return; } } } // Polygon mode — append; close handled by Enter. state.appendAddElementPendingPoint({ x: point.x, y: point.y, z: point.z }); return; } } function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } /** Signed 2D polygon area via the shoelace formula. */ function polygonArea2D(points: Array<[number, number]>): number { if (points.length < 3) return 0; let area = 0; for (let i = 0; i < points.length; i++) { const [x1, y1] = points[i]; const [x2, y2] = points[(i + 1) % points.length]; area += x1 * y2 - x2 * y1; } return area * 0.5; } /** * Close an in-progress polygon for any slab-style type * (slab / roof / plate / space). Triggered by Enter. Requires * ≥3 points; the builder's auto-closure handles the trailing edge. */ export function commitAddElementSlabPolygon(): void { const state = useViewerStore.getState(); if (state.activeTool !== 'addElement') return; const type = state.addElementType; const polygonable = type === 'slab' || type === 'roof' || type === 'plate' || type === 'space'; if (!polygonable || state.addElementSlabMode !== 'polygon') return; const pending = state.addElementPendingPoints; if (pending.length < 3) { toast.error(`${capitalize(type)} polygon needs at least 3 points`); return; } const ctx = resolveAddElementContext(); if (!ctx) return; const { modelId, storeyId } = ctx; const outer = pending.map((pt) => { const ifc = rendererPointToIfcStoreyLocal(pt); return [ifc[0], ifc[1]] as [number, number]; }); // Reject degenerate (zero-area) polygons — repeated or collinear // pending points would otherwise produce an OuterCurve that exports // as an invalid slab/roof/plate/space profile. if (Math.abs(polygonArea2D(outer)) < 1e-6) { toast.error(`${capitalize(type)} polygon must have a non-zero area`); return; } switch (type) { case 'slab': { const p = state.addElementSlabParams; finishAddElement(state.addSlab(modelId, storeyId, { Profile: 'polygon', OuterCurve: outer, Thickness: p.Thickness, }), modelId, 'Slab'); return; } case 'roof': { const p = state.addElementRoofParams; finishAddElement(state.addRoof(modelId, storeyId, { Profile: 'polygon', OuterCurve: outer, Thickness: p.Thickness, }), modelId, 'Roof'); return; } case 'plate': { const p = state.addElementPlateParams; finishAddElement(state.addPlate(modelId, storeyId, { Profile: 'polygon', OuterCurve: outer, Thickness: p.Thickness, }), modelId, 'Plate'); return; } case 'space': { const p = state.addElementSpaceParams; finishAddElement(state.addSpace(modelId, storeyId, { Profile: 'polygon', OuterCurve: outer, Height: p.Height, }), modelId, 'Space'); return; } } } /** * Handle context menu event (right-click). * Picks the entity under the cursor and opens the context menu. */ export async function handleContextMenu(ctx: MouseHandlerContext, e: MouseEvent): Promise { e.preventDefault(); const { canvas, renderer, mouseState } = ctx; // Right-drag is the pan gesture (see useMouseControls). Some browsers // still fire `contextmenu` after a tiny right-drag — skip when the // user actually moved, so panning never accidentally pops the menu. if (mouseState.didDrag) { return; } const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Uses visibility filtering so hidden elements don't appear in context menu const pickResult = await renderer.pick(x, y, ctx.getPickOptions()); ctx.openContextMenu(pickResult?.expressId ?? null, e.clientX, e.clientY); }