diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 0440e1d1ea..b42f82b065 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -13,7 +13,7 @@ import { } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import { Tooltip, TooltipContent, @@ -78,6 +78,8 @@ const BASE_BUILD_TYPES: BuildType[] = [ { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, ] +const subscribeToClientMount = () => () => {} + function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ @@ -209,7 +211,15 @@ export function BuildTab() { const floorplanMode = useFloorplanMode((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) - const buildTypes = useMemo(() => collectBuildTypes(floorplanMode), [floorplanMode]) + const registryReady = useSyncExternalStore( + subscribeToClientMount, + () => true, + () => false, + ) + const buildTypes = useMemo( + () => (registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES), + [floorplanMode, registryReady], + ) // The fitting / follow tools are armed from a segment's panel, not a grid // tile — keep the segment tile lit so the panel (and the way back) stays diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 55fdcfd9ba..799315f439 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -11,6 +11,7 @@ import type { ColumnNode, ConstructionDimensionNode, CupolaNode, + CustomMeshNode, DoorNode, DormerNode, DownspoutNode, @@ -104,6 +105,7 @@ export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent export type ColumnEvent = NodeEvent export type ConstructionDimensionEvent = NodeEvent +export type CustomMeshEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent export type StairEvent = NodeEvent @@ -158,6 +160,10 @@ type GridEvents = { [K in `grid:${EventSuffix}`]: GridEvent } +type GenericNodeEvents = { + [K in `node:${EventSuffix}`]: NodeEvent +} + export interface CameraControlEvent { nodeId: AnyNode['id'] } @@ -289,6 +295,7 @@ type SelectionEvents = { } type EditorEvents = GridEvents & + GenericNodeEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & NodeEvents<'cabinet', CabinetEvent> & @@ -305,6 +312,7 @@ type EditorEvents = GridEvents & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'column', ColumnEvent> & NodeEvents<'construction-dimension', ConstructionDimensionEvent> & + NodeEvents<'custom-mesh', CustomMeshEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'stair', StairEvent> & diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts index 7a4f432590..f2e4556a8c 100644 --- a/packages/core/src/hooks/spatial-grid/support-host-patch.ts +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -1,7 +1,13 @@ +import { levelBaseElevationAt, terrainSupportLift } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' import type { AnyNode, AnyNodeId, FenceNode, SlabNode, WallNode } from '../../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height' import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' -import { GROUND_SUPPORT_ID, getFloorPlacedFootprints } from './floor-placed-elevation' +import { + GROUND_SUPPORT_ID, + getFloorPlacedElevation, + getFloorPlacedFootprints, +} from './floor-placed-elevation' import { SUPPORT_ELEVATION_EPSILON, spatialGridManager } from './spatial-grid-manager' export type SupportSlabPatch = { supportSlabId: string | undefined } @@ -17,6 +23,18 @@ export type SupportSlabPatchOptions = { maxElevation?: number | null /** Pointer- or snap-decided host. Ground is a first-class support source. */ preferredSlabId?: string | null + /** Persist even an unambiguous host so later overlapping slabs cannot re-elect it. */ + pinSupport?: boolean +} + +export type FrozenFloorPlacementOptions = { + /** Canonical position before floor/support lift is applied. */ + position: [number, number, number] + rotation?: unknown + /** Exact level-local elevation hit on a non-slab construction surface. */ + elevation: number + /** Slab/ground supporting that construction surface, when known. */ + preferredSlabId?: string | null } export function resolveSupportSlabPatch( @@ -35,6 +53,30 @@ export function resolveSupportSlabPatch( const maxElevation = options?.maxElevation const footprints = getFloorPlacedFootprints(floorPlaced, node, { nodes }) + + if (options?.preferredSlabId === GROUND_SUPPORT_ID) { + return { supportSlabId: GROUND_SUPPORT_ID } + } + if (options?.preferredSlabId) { + for (const footprint of footprints) { + const position = footprint.position ?? (node as { position?: unknown }).position + if (!Array.isArray(position) || position.length !== 3) continue + const elevation = spatialGridManager.getHostSlabElevationForFootprint( + parent.id, + options.preferredSlabId, + position as [number, number, number], + footprint.dimensions, + footprint.rotation, + ) + if ( + elevation !== null && + (maxElevation == null || elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON) + ) { + return { supportSlabId: options.preferredSlabId } + } + } + } + const candidateElevations = new Set() let winner: { slabId: string; elevation: number } | null = null let cappedOut = false @@ -66,12 +108,61 @@ export function resolveSupportSlabPatch( } if (winner !== null) { - return { supportSlabId: candidateElevations.size >= 2 ? winner.slabId : undefined } + return { + supportSlabId: + options?.pinSupport || candidateElevations.size >= 2 ? winner.slabId : undefined, + } } // Capped election chose the ground while overlapping slabs sit above the // cap: persist the ground host, or the uncapped per-frame election would // lift the committed node back onto the deck. - return { supportSlabId: cappedOut ? GROUND_SUPPORT_ID : undefined } + return { + supportSlabId: options?.pinSupport || cappedOut ? GROUND_SUPPORT_ID : undefined, + } +} + +/** + * Freeze an exact pointed node-top elevation into a floor-placed node's + * existing canonical Y offset, while pinning the slab/ground beneath that + * surface. This deliberately does not create a live hosting edge to the + * pointed node: arbitrary mesh faces can be edited or sloped, so placement + * captures the plane the user chose at commit time. + */ +export function resolveFrozenFloorPlacementPatch( + node: AnyNode, + nodes: Record, + options: FrozenFloorPlacementOptions, +): SupportSlabPatch & { position: [number, number, number] } { + const effectiveNode = { + ...(node as Record), + position: options.position, + ...(options.rotation !== undefined ? { rotation: options.rotation } : {}), + } as AnyNode + const floorPlaced = nodeRegistry.get(effectiveNode.type)?.capabilities?.floorPlaced + if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(effectiveNode))) { + return { supportSlabId: undefined, position: options.position } + } + const supportPatch = resolveSupportSlabPatch(effectiveNode, nodes, { + maxElevation: options.elevation, + preferredSlabId: options.preferredSlabId, + pinSupport: true, + }) + const pinnedNode = { ...effectiveNode, ...supportPatch } as AnyNode + const supportElevation = getFloorPlacedElevation({ + node: pinnedNode, + nodes, + position: options.position, + rotation: options.rotation, + }) + + return { + ...supportPatch, + position: [ + options.position[0], + options.position[1] + options.elevation - supportElevation, + options.position[2], + ], + } } export function resolveWallSupportSlabPatch( @@ -212,6 +303,10 @@ export function resolveFenceSupportSlabPatch( const candidateElevations = new Set() let winner: { slabId: string; elevation: number } | null = null + if (options?.preferredSlabId === GROUND_SUPPORT_ID) { + return { supportSlabId: GROUND_SUPPORT_ID } + } + for (let i = 1; i < points.length; i++) { const [ax, az] = points[i - 1]! const [bx, bz] = points[i]! @@ -223,6 +318,22 @@ export function resolveFenceSupportSlabPatch( // (cos yRot, sin yRot) in XZ, so the segment angle aligns the band. const rotation: [number, number, number] = [0, Math.atan2(bz - az, bx - ax), 0] + if (options?.preferredSlabId) { + const preferredElevation = spatialGridManager.getHostSlabElevationForFootprint( + parent.id, + options.preferredSlabId, + position, + dimensions, + rotation, + ) + if ( + preferredElevation !== null && + (maxElevation == null || preferredElevation <= maxElevation + SUPPORT_ELEVATION_EPSILON) + ) { + return { supportSlabId: options.preferredSlabId } + } + } + const candidates = spatialGridManager.getSupportCandidatesForFootprint( parent.id, position, @@ -243,7 +354,168 @@ export function resolveFenceSupportSlabPatch( } } - if (winner === null) return { supportSlabId: undefined } + if (winner === null) { + return { supportSlabId: options?.pinSupport ? GROUND_SUPPORT_ID : undefined } + } const persist = candidateElevations.size >= 2 || winner.elevation > SUPPORT_ELEVATION_EPSILON - return { supportSlabId: persist ? winner.slabId : undefined } + return { supportSlabId: options?.pinSupport || persist ? winner.slabId : undefined } +} + +export type FenceConstructionOptions = { + supportCap?: number | null + preferredSupportSlabId?: string | null + constructionElevation?: number | null +} + +export function resolveFenceConstructionSupport( + fence: FenceNode, + levelId: string, + nodes: Record, + options?: FenceConstructionOptions, +): FenceNode { + const supportPatch = resolveFenceSupportSlabPatch({ ...fence, parentId: levelId }, nodes, { + maxElevation: options?.supportCap ?? null, + preferredSlabId: options?.preferredSupportSlabId ?? null, + pinSupport: options?.constructionElevation != null, + }) + const host = supportPatch.supportSlabId ? nodes[supportPatch.supportSlabId] : null + const baseElevation = + host?.type === 'slab' + ? host.elevation + : levelBaseElevationAt(nodes, levelId, fence.start[0], fence.start[1]) + const supportOffset = + options?.constructionElevation == null + ? fence.supportOffset + : options.constructionElevation - baseElevation + + return { + ...fence, + ...supportPatch, + supportOffset: + supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, + } +} + +export type WallConstructionOptions = { + supportCap?: number | null + preferredSupportSlabId?: string | null + constructionElevation?: number | null + constructionHeight?: number | null + flatConstructionBase?: boolean + constructionSourceNodeId?: AnyNodeId | null +} + +export function resolveTerrainWallConstructionOptions( + nodes: Record, + levelId: string, + point: readonly [number, number], + defaults?: Record, +): WallConstructionOptions | undefined { + const constructionElevation = terrainSupportLift(nodes, levelId, point[0], point[1]) + if (constructionElevation == null) return undefined + + const level = nodes[levelId] + const constructionHeight = + typeof defaults?.height === 'number' + ? defaults.height + : level?.type === 'level' + ? (level.height ?? DEFAULT_LEVEL_HEIGHT) + : DEFAULT_LEVEL_HEIGHT + + return { + constructionElevation, + constructionHeight, + supportCap: constructionElevation, + } +} + +export type WallConstructionResolution = { + walls: WallNode[] + sourceSupportUpdate: { id: AnyNodeId; data: SupportSlabPatch } | null +} + +export function resolveWallConstruction( + nodes: Record, + levelId: string, + walls: readonly WallNode[], + options?: WallConstructionOptions, +): WallConstructionResolution { + let resolvedNodes = nodes + let sourceSupportUpdate: WallConstructionResolution['sourceSupportUpdate'] = null + const constructionSourceNodeId = options?.constructionSourceNodeId + if (constructionSourceNodeId) { + const sourceNode = nodes[constructionSourceNodeId] + const currentSupport = + sourceNode && 'supportSlabId' in sourceNode + ? (sourceNode.supportSlabId as string | undefined) + : undefined + if (sourceNode && currentSupport == null) { + const data = resolveSupportSlabPatch(sourceNode, nodes, { pinSupport: true }) + if (data.supportSlabId != null) { + sourceSupportUpdate = { id: constructionSourceNodeId, data } + resolvedNodes = { + ...nodes, + [constructionSourceNodeId]: { ...sourceNode, ...data } as AnyNode, + } + } + } + } + + const resolvedWalls = walls.map((createdWall) => { + const wallWithParent = { ...createdWall, parentId: levelId as AnyNodeId } as WallNode + const terrainBase = terrainSupportLift( + resolvedNodes, + levelId, + createdWall.start[0], + createdWall.start[1], + ) + const wallOptions = + options?.preferredSupportSlabId === GROUND_SUPPORT_ID && + terrainBase == null && + !options.flatConstructionBase + ? undefined + : options + const preferredSupportSlabId = + wallOptions?.flatConstructionBase === true + ? GROUND_SUPPORT_ID + : (wallOptions?.preferredSupportSlabId ?? + (wallOptions?.constructionElevation != null && terrainBase != null + ? GROUND_SUPPORT_ID + : null)) + const supportPatch = resolveWallSupportSlabPatch(wallWithParent, resolvedNodes, { + maxElevation: wallOptions?.supportCap ?? null, + preferredSlabId: preferredSupportSlabId, + }) + const sourceSupport = spatialGridManager.getSlabSupportForWall( + levelId, + createdWall.start, + createdWall.end, + createdWall.curveOffset, + createdWall.thickness, + supportPatch.supportSlabId, + wallOptions?.supportCap ?? null, + ) + const groundDraft = + preferredSupportSlabId === GROUND_SUPPORT_ID && + (terrainBase != null || wallOptions?.flatConstructionBase === true) + const supportOffset = + groundDraft && wallOptions?.constructionElevation != null + ? wallOptions.constructionElevation - sourceSupport.elevation + : undefined + const preserveDraftHeight = + groundDraft && + createdWall.height == null && + wallOptions?.constructionHeight != null && + wallOptions.constructionElevation != null + + return { + ...wallWithParent, + ...supportPatch, + height: preserveDraftHeight ? wallOptions.constructionHeight : createdWall.height, + supportOffset: + supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, + } as WallNode + }) + + return { walls: resolvedWalls, sourceSupportUpdate } } diff --git a/packages/core/src/hooks/spatial-grid/support-host.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts index 0138ed7150..0e3580c7ad 100644 --- a/packages/core/src/hooks/spatial-grid/support-host.test.ts +++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts @@ -12,6 +12,7 @@ import { GROUND_SUPPORT_ID, getFloorPlacedElevation } from './floor-placed-eleva import { getWallBaseElevationForNodes, spatialGridManager } from './spatial-grid-manager' import { initSpatialGridSync } from './spatial-grid-sync' import { + resolveFrozenFloorPlacementPatch, resolveMovedWallSupportSlabPatch, resolveSupportSlabPatch, resolveWallSupportSlabPatch, @@ -271,6 +272,60 @@ describe('persisted support hosts (items)', () => { }) }) + test('a pinned placement is not lifted by a slab generated above it later', () => { + registerFloorPlacedItem() + + const level = makeLevel() + const node = makeFloorNode() + const supportPatch = resolveSupportSlabPatch(node, nodesFor(level, node), { + pinSupport: true, + }) + expect(supportPatch).toEqual({ supportSlabId: GROUND_SUPPORT_ID }) + + const pinnedNode = makeFloorNode(supportPatch as Partial) + addSlab(makeSlab('slab_generated', SQUARE, 2.45, { autoFromWalls: true })) + + expect( + getFloorPlacedElevation({ + node: pinnedNode, + nodes: nodesFor(level, pinnedNode), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBe(0) + }) + + test('a frozen node-top placement keeps its exact height when a slab appears later', () => { + registerFloorPlacedItem() + const low = makeSlab('slab_low', SQUARE, 0.25) + addSlab(low) + + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node, low as AnyNode) + const patch = resolveFrozenFloorPlacementPatch(node, nodes, { + position: [0, 0, 0], + rotation: [0, 0, 0], + elevation: 2, + preferredSlabId: low.id, + }) + + expect(patch).toEqual({ supportSlabId: low.id, position: [0, 1.75, 0] }) + + const placed = makeFloorNode(patch as Partial) + const generated = makeSlab('slab_generated', SQUARE, 1.5, { autoFromWalls: true }) + addSlab(generated) + expect( + placed.position[1] + + getFloorPlacedElevation({ + node: placed, + nodes: nodesFor(level, placed, low as AnyNode, generated as AnyNode), + position: placed.position, + rotation: placed.rotation, + }), + ).toBeCloseTo(2) + }) + test('item support follows the RENDERED slab polygon (wall band adoption)', () => { registerFloorPlacedItem() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 29b23c209e..fce59ad807 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export type { ChimneyEvent, ColumnEvent, ConstructionDimensionEvent, + CustomMeshEvent, DoorEvent, DormerEvent, ElevatorEvent, @@ -70,13 +71,21 @@ export { resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' export { + type FenceConstructionOptions, type FenceSupportInput, + type FrozenFloorPlacementOptions, + resolveFenceConstructionSupport, resolveFenceSupportSlabPatch, + resolveFrozenFloorPlacementPatch, resolveMovedWallSupportSlabPatch, resolveSupportSlabPatch, + resolveTerrainWallConstructionOptions, + resolveWallConstruction, resolveWallSupportSlabPatch, type SupportSlabPatch, type SupportSlabPatchOptions, + type WallConstructionOptions, + type WallConstructionResolution, } from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index d85da0d846..4e51be1554 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -2022,7 +2022,9 @@ export type SnappableConfig = { export type SnapPointKind = 'start' | 'end' | 'midpoint' | 'center' | 'corners' export type SurfacesConfig = { - top?: { height: number | ((n: AnyNode) => number) } + top?: { + height: number | ((n: AnyNode, context: { nodes: Record }) => number) + } sides?: { faces: 'all' | ReadonlyArray } custom?: SurfaceQuery } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index d11bdec7d1..1ab29bf8b5 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -74,6 +74,18 @@ export { setConstructionDimensionDrawingSuppressedSegments, } from './nodes/construction-dimension' export { CupolaNode } from './nodes/cupola' +export { + CUSTOM_MESH_BODY_MATERIAL_REF, + CustomMeshEdge, + CustomMeshFace, + CustomMeshNode, + CustomMeshTopology, + type CustomMeshTopologyIssue, + CustomMeshVertex, + createBoxCustomMeshTopology, + customMeshUndirectedEdgeKey, + inspectCustomMeshTopology, +} from './nodes/custom-mesh' export { DoorNode, DoorSegment, diff --git a/packages/core/src/schema/nodes/custom-mesh.test.ts b/packages/core/src/schema/nodes/custom-mesh.test.ts new file mode 100644 index 0000000000..d9857ebca3 --- /dev/null +++ b/packages/core/src/schema/nodes/custom-mesh.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { + CUSTOM_MESH_BODY_MATERIAL_REF, + CustomMeshNode, + CustomMeshTopology, + createBoxCustomMeshTopology, + inspectCustomMeshTopology, +} from './custom-mesh' + +describe('CustomMeshNode', () => { + test('creates a valid topology-backed box by default', () => { + const node = CustomMeshNode.parse({ name: 'Editable box' }) + + expect(node.topology.vertices).toHaveLength(8) + expect(node.topology.edges).toHaveLength(12) + expect(node.topology.faces).toHaveLength(6) + expect(node.slots).toEqual({ body: CUSTOM_MESH_BODY_MATERIAL_REF }) + expect(inspectCustomMeshTopology(node.topology)).toEqual([]) + }) + + test('retains its pinned placement support', () => { + const node = CustomMeshNode.parse({ + name: 'Supported platform', + supportSlabId: 'ground', + }) + + expect(node.supportSlabId).toBe('ground') + }) + + test('repairs legacy slot maps that predate the reusable body binding', () => { + const node = CustomMeshNode.parse({ + name: 'Legacy painted mesh', + slots: { accent: 'library:metal-steel' }, + }) + + expect(node.slots).toEqual({ + body: CUSTOM_MESH_BODY_MATERIAL_REF, + accent: 'library:metal-steel', + }) + }) + + test('rejects a face loop without a persisted boundary edge', () => { + const topology = createBoxCustomMeshTopology() + topology.edges = topology.edges.filter((edge) => edge.id !== 'e4') + + expect(CustomMeshTopology.safeParse(topology).success).toBe(false) + }) +}) diff --git a/packages/core/src/schema/nodes/custom-mesh.ts b/packages/core/src/schema/nodes/custom-mesh.ts new file mode 100644 index 0000000000..44c425b3ab --- /dev/null +++ b/packages/core/src/schema/nodes/custom-mesh.ts @@ -0,0 +1,179 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const CustomMeshVertex = z.object({ + id: z.string().min(1), + position: z.tuple([z.number(), z.number(), z.number()]), +}) + +export const CustomMeshEdge = z.object({ + id: z.string().min(1), + vertexIds: z.tuple([z.string().min(1), z.string().min(1)]), +}) + +export const CustomMeshFace = z.object({ + id: z.string().min(1), + vertexIds: z.array(z.string().min(1)).min(3), + materialSlot: z.string().min(1).default('body'), +}) + +const CustomMeshTopologyShape = z.object({ + vertices: z.array(CustomMeshVertex), + edges: z.array(CustomMeshEdge), + faces: z.array(CustomMeshFace), +}) + +export type CustomMeshVertex = z.infer +export type CustomMeshEdge = z.infer +export type CustomMeshFace = z.infer +export type CustomMeshTopology = z.infer + +export type CustomMeshTopologyIssue = { + path: (string | number)[] + message: string +} + +export const CUSTOM_MESH_BODY_MATERIAL_REF = 'library:concrete-drywall' + +export function customMeshUndirectedEdgeKey(a: string, b: string) { + return a < b ? `${a}\u0000${b}` : `${b}\u0000${a}` +} + +export function inspectCustomMeshTopology(topology: CustomMeshTopology): CustomMeshTopologyIssue[] { + const issues: CustomMeshTopologyIssue[] = [] + const vertexIds = new Set() + const edgeIds = new Set() + const faceIds = new Set() + const edgeKeys = new Set() + + topology.vertices.forEach((vertex, index) => { + if (vertexIds.has(vertex.id)) { + issues.push({ path: ['vertices', index, 'id'], message: `Duplicate vertex id: ${vertex.id}` }) + } + vertexIds.add(vertex.id) + }) + + topology.edges.forEach((edge, index) => { + if (edgeIds.has(edge.id)) { + issues.push({ path: ['edges', index, 'id'], message: `Duplicate edge id: ${edge.id}` }) + } + edgeIds.add(edge.id) + const [a, b] = edge.vertexIds + if (a === b) { + issues.push({ path: ['edges', index, 'vertexIds'], message: 'An edge needs two vertices' }) + } + edge.vertexIds.forEach((vertexId, vertexIndex) => { + if (!vertexIds.has(vertexId)) { + issues.push({ + path: ['edges', index, 'vertexIds', vertexIndex], + message: `Unknown vertex id: ${vertexId}`, + }) + } + }) + const key = customMeshUndirectedEdgeKey(a, b) + if (edgeKeys.has(key)) { + issues.push({ path: ['edges', index], message: `Duplicate edge: ${a}–${b}` }) + } + edgeKeys.add(key) + }) + + topology.faces.forEach((face, index) => { + if (faceIds.has(face.id)) { + issues.push({ path: ['faces', index, 'id'], message: `Duplicate face id: ${face.id}` }) + } + faceIds.add(face.id) + if (new Set(face.vertexIds).size < 3) { + issues.push({ path: ['faces', index, 'vertexIds'], message: 'A face needs three vertices' }) + } + face.vertexIds.forEach((vertexId, vertexIndex) => { + if (!vertexIds.has(vertexId)) { + issues.push({ + path: ['faces', index, 'vertexIds', vertexIndex], + message: `Unknown vertex id: ${vertexId}`, + }) + } + const nextVertexId = face.vertexIds[(vertexIndex + 1) % face.vertexIds.length] + if (nextVertexId && !edgeKeys.has(customMeshUndirectedEdgeKey(vertexId, nextVertexId))) { + issues.push({ + path: ['faces', index, 'vertexIds', vertexIndex], + message: `Missing edge for face boundary: ${vertexId}–${nextVertexId}`, + }) + } + }) + }) + + return issues +} + +export const CustomMeshTopology = CustomMeshTopologyShape.superRefine((topology, context) => { + for (const issue of inspectCustomMeshTopology(topology)) { + context.addIssue({ code: 'custom', path: issue.path, message: issue.message }) + } +}) + +export function createBoxCustomMeshTopology( + width = 2, + height = 2.4, + depth = 2, +): CustomMeshTopology { + const halfWidth = width / 2 + const halfDepth = depth / 2 + return { + vertices: [ + { id: 'v0', position: [-halfWidth, 0, -halfDepth] }, + { id: 'v1', position: [halfWidth, 0, -halfDepth] }, + { id: 'v2', position: [halfWidth, 0, halfDepth] }, + { id: 'v3', position: [-halfWidth, 0, halfDepth] }, + { id: 'v4', position: [-halfWidth, height, -halfDepth] }, + { id: 'v5', position: [halfWidth, height, -halfDepth] }, + { id: 'v6', position: [halfWidth, height, halfDepth] }, + { id: 'v7', position: [-halfWidth, height, halfDepth] }, + ], + edges: [ + { id: 'e0', vertexIds: ['v0', 'v1'] }, + { id: 'e1', vertexIds: ['v1', 'v2'] }, + { id: 'e2', vertexIds: ['v2', 'v3'] }, + { id: 'e3', vertexIds: ['v3', 'v0'] }, + { id: 'e4', vertexIds: ['v4', 'v5'] }, + { id: 'e5', vertexIds: ['v5', 'v6'] }, + { id: 'e6', vertexIds: ['v6', 'v7'] }, + { id: 'e7', vertexIds: ['v7', 'v4'] }, + { id: 'e8', vertexIds: ['v0', 'v4'] }, + { id: 'e9', vertexIds: ['v1', 'v5'] }, + { id: 'e10', vertexIds: ['v2', 'v6'] }, + { id: 'e11', vertexIds: ['v3', 'v7'] }, + ], + faces: [ + { id: 'f-bottom', vertexIds: ['v0', 'v1', 'v2', 'v3'], materialSlot: 'body' }, + { id: 'f-top', vertexIds: ['v4', 'v7', 'v6', 'v5'], materialSlot: 'body' }, + { id: 'f-front', vertexIds: ['v0', 'v4', 'v5', 'v1'], materialSlot: 'body' }, + { id: 'f-right', vertexIds: ['v1', 'v5', 'v6', 'v2'], materialSlot: 'body' }, + { id: 'f-back', vertexIds: ['v2', 'v6', 'v7', 'v3'], materialSlot: 'body' }, + { id: 'f-left', vertexIds: ['v3', 'v7', 'v4', 'v0'], materialSlot: 'body' }, + ], + } +} + +export const CustomMeshNode = BaseNode.extend({ + id: objectId('custom-mesh'), + type: nodeType('custom-mesh'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + supportSlabId: z.string().optional(), + topology: CustomMeshTopology.default(createBoxCustomMeshTopology), + slots: z + .record(z.string(), z.string()) + .default({}) + .transform( + (slots): Record => ({ body: CUSTOM_MESH_BODY_MATERIAL_REF, ...slots }), + ), +}).describe(dedent` + Custom mesh node - a topology-backed editable solid. + - topology: persistent vertices, edges, and ordered face loops with stable IDs + - position/rotation: level-local placement transform + - supportSlabId: persisted placement surface that prevents later slabs from lifting the mesh + - slots: material references keyed by face materialSlot; body always starts reusable +`) + +export type CustomMeshNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 2032c61a7e..3dbe3e0010 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -4,6 +4,7 @@ import { BaseNode, nodeType, objectId } from '../base' import type { CeilingNode } from './ceiling' import type { ColumnNode } from './column' import type { ConstructionDimensionNode } from './construction-dimension' +import type { CustomMeshNode } from './custom-mesh' import type { DuctFittingNode } from './duct-fitting' import type { DuctSegmentNode } from './duct-segment' import type { DuctTerminalNode } from './duct-terminal' @@ -32,6 +33,7 @@ type CoreLevelChildId = | FenceNode['id'] | ColumnNode['id'] | ConstructionDimensionNode['id'] + | CustomMeshNode['id'] | StructuralGridNode['id'] | ItemNode['id'] | ZoneNode['id'] diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 70575bcfb9..f52b5e05cc 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -7,6 +7,7 @@ import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' import { ConstructionDimensionNode } from './nodes/construction-dimension' import { CupolaNode } from './nodes/cupola' +import { CustomMeshNode } from './nodes/custom-mesh' import { DoorNode } from './nodes/door' import { DormerNode } from './nodes/dormer' import { DownspoutNode } from './nodes/downspout' @@ -52,6 +53,7 @@ export const AnyNode = z.discriminatedUnion('type', [ LevelNode, ColumnNode, ConstructionDimensionNode, + CustomMeshNode, StructuralGridNode, WallNode, FenceNode, diff --git a/packages/core/src/services/hosting.test.ts b/packages/core/src/services/hosting.test.ts index 55934aeca4..a75719c5aa 100644 --- a/packages/core/src/services/hosting.test.ts +++ b/packages/core/src/services/hosting.test.ts @@ -192,6 +192,22 @@ describe('getSurface / getTopSurfaceHeight', () => { expect(getTopSurfaceHeight(makeNode('shelf', 'high'))).toBe(1.8) expect(getTopSurfaceHeight(makeNode('shelf', 'low'))).toBe(0.3) }) + + test('passes the complete node record to context-aware surface resolvers', () => { + const host = makeNode('platform', 'platform') + const support = makeNode('support', 'support') + registerNode( + makeDef('platform', { + surfaces: { + top: { + height: (_node: any, { nodes }: any) => (nodes[id('support')] ? 2.4 : 0), + }, + }, + }), + ) + + expect(getTopSurfaceHeight(host, { [host.id]: host, [support.id]: support })).toBe(2.4) + }) }) describe('clampYToHostTop', () => { diff --git a/packages/core/src/services/hosting.ts b/packages/core/src/services/hosting.ts index 9394f586a1..7026ade37c 100644 --- a/packages/core/src/services/hosting.ts +++ b/packages/core/src/services/hosting.ts @@ -105,11 +105,14 @@ export function getSurface(host: AnyNode): SurfacesConfig | null { * Resolves the stackable top height of a host (e.g. table surface, slab top, * stair landing). Returns `null` when the host has no `surfaces.top`. */ -export function getTopSurfaceHeight(host: AnyNode): number | null { +export function getTopSurfaceHeight( + host: AnyNode, + nodes: Record = { [host.id]: host }, +): number | null { const surfaces = getSurface(host) if (!surfaces?.top) return null const { height } = surfaces.top - return typeof height === 'function' ? height(host) : height + return typeof height === 'function' ? height(host, { nodes }) : height } /** @@ -152,7 +155,11 @@ export function pickHost(args: { * Convenience: clamps a Y coordinate to the top of a host surface, when one * is declared. Returns the original Y if the host has no top surface. */ -export function clampYToHostTop(host: AnyNode, originalY: number): number { - const top = getTopSurfaceHeight(host) +export function clampYToHostTop( + host: AnyNode, + originalY: number, + nodes?: Record, +): number { + const top = getTopSurfaceHeight(host, nodes) return top == null ? originalY : top } diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 1091ced9e5..01af884e01 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -43,6 +43,7 @@ import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' import { useReducedMotion } from '../../hooks/use-reduced-motion' import { resolveMoveActionNode } from '../../lib/direct-manipulation' +import { getFloatingMenuScale } from '../../lib/floating-menu-scale' import { createFreshPlacementSubtree, duplicatesAsFreshSubtree, @@ -99,17 +100,6 @@ const ALLOWED_TYPES = [ const DELETE_ONLY_TYPES: string[] = [] const HOLE_TYPES = ['slab', 'ceiling'] -// Menu scales with camera zoom so it feels anchored to the object, but is -// clamped on both ends so it stays readable when zoomed way out and doesn't -// dominate the screen when zoomed in close. Reference values are picked so -// scale = 1 lands near the editor's default framing. -const MIN_MENU_SCALE = 0.5 -// Cap at 1 so zooming in doesn't grow the menu past its default pixel size — -// only zoom-out shrinks it (down to MIN_MENU_SCALE). -const MAX_MENU_SCALE = 1 -const REF_ORTHO_ZOOM = 20 -const REF_CAMERA_DISTANCE = 12 - // World-space Y distance from a node's bbox top to the floating menu anchor. // Per-type because in-world chrome above the node (height-resize arrows, // measurement labels) varies in vertical reach. @@ -392,12 +382,7 @@ export function FloatingActionMenu() { // so it stays readable at extreme zoom-out and doesn't fill the screen // when zoomed in close. if (menuScaleRef.current) { - const raw = - state.camera instanceof THREE.OrthographicCamera - ? state.camera.zoom / REF_ORTHO_ZOOM - : REF_CAMERA_DISTANCE / - Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001) - const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw)) + const scale = getFloatingMenuScale(state.camera, groupRef.current.position) menuScaleRef.current.style.transform = `scale(${scale})` } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 99c771931b..36a7037cb0 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -33,6 +33,7 @@ import { type RoofNode, type RoofSegmentNode, resolveSlabPlacementElevation, + resolveTerrainWallConstructionOptions, type SiteNode, type SlabNode, SlabNode as SlabNodeSchema, @@ -184,7 +185,6 @@ import { chainEndJoinsExistingWall, createWallOnCurrentLevel, isSegmentLongEnough, - resolveTerrainWallConstructionOptions, snapWallDraftPoint, snapWallDraftPointDetailed, snapPointToGrid as snapWallPointToGrid, diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index a503d24ba0..bb1ca78dad 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -1,7 +1,7 @@ 'use client' import { Icon } from '@iconify/react' -import { Copy, Group, Move, Search, Spline, Trash2, Ungroup } from 'lucide-react' +import { Copy, Group, Move, PencilRuler, Search, Spline, Trash2, Ungroup } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' type NodeActionMenuProps = { @@ -10,6 +10,7 @@ type NodeActionMenuProps = { onDelete?: MouseEventHandler onDuplicate?: MouseEventHandler onMove?: MouseEventHandler + onEditMesh?: MouseEventHandler onCurve?: MouseEventHandler /** Session group (Ctrl/Cmd+G) — multi-selection floating pill. */ onGroup?: MouseEventHandler @@ -27,6 +28,7 @@ export function NodeActionMenu({ onDelete, onDuplicate, onMove, + onEditMesh, onCurve, onGroup, onUngroup, @@ -65,6 +67,17 @@ export function NodeActionMenu({ )} + {onEditMesh && ( + + )} {onGroup && ( + + {index > 0 ? ( + + ) : null} + + ) + })} + + +
+ + +
+ +
+ filterSelection('select')} + /> + filterSelection('deselect')} + /> + +
+ + + + + } label="Move" onClick={move} /> + } + label="Delete" + onClick={() => { + useScene.getState().deleteNode(node.id) + setViewerSelection({ selectedIds: [] }) + }} + /> + + + + ) +} diff --git a/packages/nodes/src/custom-mesh/parametrics.ts b/packages/nodes/src/custom-mesh/parametrics.ts new file mode 100644 index 0000000000..1b31a835f2 --- /dev/null +++ b/packages/nodes/src/custom-mesh/parametrics.ts @@ -0,0 +1,12 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { CustomMeshNode } from './schema' + +export const customMeshParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], + customPanel: () => import('./panel'), +} diff --git a/packages/nodes/src/custom-mesh/preview.tsx b/packages/nodes/src/custom-mesh/preview.tsx new file mode 100644 index 0000000000..1b75fa119e --- /dev/null +++ b/packages/nodes/src/custom-mesh/preview.tsx @@ -0,0 +1,63 @@ +'use client' + +import type { CustomMeshNode } from '@pascal-app/core' +import { EDITOR_LAYER } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo } from 'react' +import { Color, type Material, Mesh } from 'three' +import { buildCustomMeshGeometry } from './geometry' + +export default function CustomMeshPreview({ + node, + valid = true, +}: { + node: CustomMeshNode + valid?: boolean +}) { + const shading = useViewer((state) => state.shading) + const textures = useViewer((state) => state.textures) + const colorPreset = useViewer((state) => state.colorPreset) + const sceneTheme = useViewer((state) => state.sceneTheme) + const preview = useMemo(() => { + const next = buildCustomMeshGeometry( + node, + undefined, + shading, + textures, + colorPreset, + sceneTheme, + ) + const ownedMaterials: Material[] = [] + next.traverse((child) => { + child.layers.set(EDITOR_LAYER) + child.raycast = () => {} + if (!(child instanceof Mesh)) return + const sourceMaterials = Array.isArray(child.material) ? child.material : [child.material] + const materials = sourceMaterials.map((material) => material.clone()) + for (const material of materials) { + material.transparent = true + material.opacity = 0.52 + material.depthWrite = false + if (!valid && 'color' in material && material.color instanceof Color) { + material.color.set('#ef4444') + } + } + ownedMaterials.push(...materials) + child.material = Array.isArray(child.material) ? materials : materials[0]! + }) + return { object: next, ownedMaterials } + }, [colorPreset, node, sceneTheme, shading, textures, valid]) + + useEffect( + () => () => { + preview.object.traverse((child) => { + if (!(child instanceof Mesh)) return + child.geometry.dispose() + }) + for (const material of preview.ownedMaterials) material.dispose() + }, + [preview], + ) + + return +} diff --git a/packages/nodes/src/custom-mesh/rotation-drag.test.ts b/packages/nodes/src/custom-mesh/rotation-drag.test.ts new file mode 100644 index 0000000000..e10d3055a2 --- /dev/null +++ b/packages/nodes/src/custom-mesh/rotation-drag.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { Vector3 } from 'three' +import { signedAngleAroundAxis, unwrapRotationDelta } from './rotation-drag' + +describe('custom mesh rotation drag', () => { + test('derives rotation direction around the chosen axis', () => { + const from = new Vector3(1, 0, 0) + const to = new Vector3(0, 0, -1) + + expect(signedAngleAroundAxis(from, to, new Vector3(0, 1, 0))).toBeCloseTo(Math.PI / 2) + expect(signedAngleAroundAxis(from, to, new Vector3(0, -1, 0))).toBeCloseTo(-Math.PI / 2) + }) + + test('continues smoothly when the pointer crosses the angle seam', () => { + const previous = (179 * Math.PI) / 180 + const current = (-179 * Math.PI) / 180 + + expect(unwrapRotationDelta(previous, current)).toBeCloseTo((2 * Math.PI) / 180) + expect(unwrapRotationDelta(current, previous)).toBeCloseTo((-2 * Math.PI) / 180) + }) +}) diff --git a/packages/nodes/src/custom-mesh/rotation-drag.ts b/packages/nodes/src/custom-mesh/rotation-drag.ts new file mode 100644 index 0000000000..b171a66f6e --- /dev/null +++ b/packages/nodes/src/custom-mesh/rotation-drag.ts @@ -0,0 +1,12 @@ +import type { Vector3 } from 'three' + +export function signedAngleAroundAxis(from: Vector3, to: Vector3, axis: Vector3): number { + return Math.atan2(axis.dot(from.clone().cross(to)), from.dot(to)) +} + +export function unwrapRotationDelta(previous: number, current: number): number { + let delta = current - previous + if (delta > Math.PI) delta -= Math.PI * 2 + if (delta < -Math.PI) delta += Math.PI * 2 + return delta +} diff --git a/packages/nodes/src/custom-mesh/schema.ts b/packages/nodes/src/custom-mesh/schema.ts new file mode 100644 index 0000000000..bf4e810434 --- /dev/null +++ b/packages/nodes/src/custom-mesh/schema.ts @@ -0,0 +1 @@ +export { CustomMeshNode } from '@pascal-app/core' diff --git a/packages/nodes/src/custom-mesh/selection-model.test.ts b/packages/nodes/src/custom-mesh/selection-model.test.ts new file mode 100644 index 0000000000..ee5cbfb240 --- /dev/null +++ b/packages/nodes/src/custom-mesh/selection-model.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import { createBoxCustomMeshTopology } from '@pascal-app/core' +import { + convertCustomMeshSelection, + createCustomMeshSelection, + invertCustomMeshSelection, + selectAllCustomMeshComponents, + selectCustomMeshComponent, +} from './selection-model' + +describe('custom mesh component selection', () => { + const topology = createBoxCustomMeshTopology() + + test('tracks the last selected component as active and supports toggling', () => { + let selection = createCustomMeshSelection('vertex') + selection = selectCustomMeshComponent(selection, 'v0', false) + selection = selectCustomMeshComponent(selection, 'v1', true) + expect(selection).toEqual({ mode: 'vertex', ids: ['v0', 'v1'], activeId: 'v1' }) + + selection = selectCustomMeshComponent(selection, 'v1', true) + expect(selection).toEqual({ mode: 'vertex', ids: ['v0'], activeId: 'v0' }) + }) + + test('converts face selection through topology instead of discarding it', () => { + const face = createCustomMeshSelection('face', ['f-top']) + const vertices = convertCustomMeshSelection(topology, face, 'vertex') + expect(vertices.ids).toEqual(['v4', 'v5', 'v6', 'v7']) + + const edges = convertCustomMeshSelection(topology, face, 'edge') + expect(edges.ids).toEqual(['e4', 'e5', 'e6', 'e7']) + }) + + test('select all and invert operate on the active component domain', () => { + const all = selectAllCustomMeshComponents(topology, createCustomMeshSelection('face')) + expect(all.ids).toHaveLength(6) + expect(invertCustomMeshSelection(topology, all).ids).toEqual([]) + + const inverse = invertCustomMeshSelection( + topology, + createCustomMeshSelection('edge', ['e0', 'e1']), + ) + expect(inverse.ids).toHaveLength(10) + expect(inverse.ids).not.toContain('e0') + }) +}) diff --git a/packages/nodes/src/custom-mesh/selection-model.ts b/packages/nodes/src/custom-mesh/selection-model.ts new file mode 100644 index 0000000000..6ecb080ade --- /dev/null +++ b/packages/nodes/src/custom-mesh/selection-model.ts @@ -0,0 +1,114 @@ +import type { CustomMeshTopology } from '@pascal-app/core' + +export type CustomMeshComponentMode = 'vertex' | 'edge' | 'face' + +export type CustomMeshSelection = { + mode: CustomMeshComponentMode + ids: string[] +} + +export type CustomMeshSelectionState = CustomMeshSelection & { + activeId: string | null +} + +function idsForMode(topology: CustomMeshTopology, mode: CustomMeshComponentMode): string[] { + switch (mode) { + case 'vertex': + return topology.vertices.map((vertex) => vertex.id) + case 'edge': + return topology.edges.map((edge) => edge.id) + case 'face': + return topology.faces.map((face) => face.id) + } +} + +function selectedVertexIds( + topology: CustomMeshTopology, + selection: CustomMeshSelection, +): Set { + const selected = new Set(selection.ids) + if (selection.mode === 'vertex') return selected + const vertices = new Set() + if (selection.mode === 'edge') { + for (const edge of topology.edges) { + if (!selected.has(edge.id)) continue + vertices.add(edge.vertexIds[0]) + vertices.add(edge.vertexIds[1]) + } + return vertices + } + for (const face of topology.faces) { + if (!selected.has(face.id)) continue + for (const vertexId of face.vertexIds) vertices.add(vertexId) + } + return vertices +} + +export function createCustomMeshSelection( + mode: CustomMeshComponentMode, + ids: string[] = [], +): CustomMeshSelectionState { + return { mode, ids, activeId: ids.at(-1) ?? null } +} + +export function selectCustomMeshComponent( + selection: CustomMeshSelectionState, + id: string, + additive: boolean, +): CustomMeshSelectionState { + if (!additive) return { ...selection, ids: [id], activeId: id } + if (!selection.ids.includes(id)) { + return { ...selection, ids: [...selection.ids, id], activeId: id } + } + const ids = selection.ids.filter((entry) => entry !== id) + return { ...selection, ids, activeId: ids.at(-1) ?? null } +} + +export function convertCustomMeshSelection( + topology: CustomMeshTopology, + selection: CustomMeshSelectionState, + nextMode: CustomMeshComponentMode, +): CustomMeshSelectionState { + if (selection.mode === nextMode) return selection + const vertices = selectedVertexIds(topology, selection) + let ids: string[] + switch (nextMode) { + case 'vertex': + ids = topology.vertices.filter((vertex) => vertices.has(vertex.id)).map((vertex) => vertex.id) + break + case 'edge': + ids = topology.edges + .filter((edge) => vertices.has(edge.vertexIds[0]) && vertices.has(edge.vertexIds[1])) + .map((edge) => edge.id) + break + case 'face': + ids = topology.faces + .filter((face) => face.vertexIds.every((vertexId) => vertices.has(vertexId))) + .map((face) => face.id) + break + } + return { mode: nextMode, ids, activeId: ids.at(-1) ?? null } +} + +export function selectAllCustomMeshComponents( + topology: CustomMeshTopology, + selection: CustomMeshSelectionState, +): CustomMeshSelectionState { + const ids = idsForMode(topology, selection.mode) + return { ...selection, ids, activeId: ids.at(-1) ?? null } +} + +export function invertCustomMeshSelection( + topology: CustomMeshTopology, + selection: CustomMeshSelectionState, +): CustomMeshSelectionState { + const selected = new Set(selection.ids) + const ids = idsForMode(topology, selection.mode).filter((id) => !selected.has(id)) + return { ...selection, ids, activeId: ids.at(-1) ?? null } +} + +export function clearCustomMeshSelection( + selection: CustomMeshSelectionState, +): CustomMeshSelectionState { + return { ...selection, ids: [], activeId: null } +} diff --git a/packages/nodes/src/custom-mesh/selection.tsx b/packages/nodes/src/custom-mesh/selection.tsx new file mode 100644 index 0000000000..986eece357 --- /dev/null +++ b/packages/nodes/src/custom-mesh/selection.tsx @@ -0,0 +1,2879 @@ +'use client' + +import { + type AnyNodeId, + type CustomMeshFace, + type CustomMeshNode, + type CustomMeshTopology, + emitter, + sceneRegistry, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { + cn, + EDITOR_LAYER, + getFloatingMenuScale, + isAngleSnapActive, + isGridSnapActive, + markToolCancelConsumed, + meshEditScope, + NodeActionMenu, + swallowNextClick, + triggerSFX, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' +import { + ArrowUpFromLine, + Check, + ChevronDown, + CircleDot, + Ellipsis, + Eye, + EyeOff, + Move3D, + Rows3, + Scaling, + ScanLine, + Square, + Trash2, + X as XIcon, +} from 'lucide-react' +import { + type MouseEvent as ReactMouseEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + BufferGeometry, + ConeGeometry, + CylinderGeometry, + DoubleSide, + Float32BufferAttribute, + type Group, + LineSegments, + type Object3D, + Plane, + PlaneGeometry, + Quaternion, + Raycaster, + SphereGeometry, + TorusGeometry, + Vector2, + Vector3, +} from 'three' +import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' +import { + applyCustomMeshCommand, + type CustomMeshCommand, + type CustomMeshSelection, + customMeshFaceCentroid, + customMeshFaceNormal, + customMeshLoopCutSegments, + customMeshSelectionVertexIds, +} from './commands' +import useCustomMeshEditSession from './edit-session' +import { triangulateCustomMeshFace } from './geometry' +import { CUSTOM_MESH_WHEEL_OPTIONS, consumeCustomMeshGestureWheel } from './gesture-wheel' +import { type CustomMeshSfxAction, customMeshSfx } from './interaction-sfx' +import { resolveLoopCutPointerAction, resolveLoopCutSlideFactor } from './loop-cut-interaction' +import { signedAngleAroundAxis, unwrapRotationDelta } from './rotation-drag' +import { + type CustomMeshSelectionState, + clearCustomMeshSelection, + convertCustomMeshSelection, + invertCustomMeshSelection, + selectAllCustomMeshComponents, + selectCustomMeshComponent, +} from './selection-model' +import { + customMeshBevelWidthFromDrag, + customMeshComponentStatus, + customMeshOperationAvailability, + customMeshScaleFactorFromDrag, + customMeshScaleFactors, + formatCustomMeshSelectionStatus, +} from './toolbar-state' + +type ComponentMode = CustomMeshSelection['mode'] +type Point = [number, number, number] +type Axis = 'x' | 'y' | 'z' +type PlaneAxes = 'xy' | 'xz' | 'yz' +type TransformOperation = 'translate' | 'rotate' | 'scale' +type ActiveTransform = { + operation: TransformOperation + constraint: Axis +} +type TransformTool = 'transform' | 'loop-cut' | 'bevel' +type TopologyOperator = 'extrude' | 'inset' | 'merge' | 'dissolve' | 'delete' +type ToolbarPanel = 'operations' | 'selection' | null + +const AXIS_VECTORS: Record = { + x: [1, 0, 0], + y: [0, 1, 0], + z: [0, 0, 1], +} +const AXIS_COLORS: Record = { + x: '#ff2060', + y: '#20df80', + z: '#2080ff', +} +const PIVOT_HOVERED_COLOR = '#ffff40' +const GIZMO_RENDER_ORDER = 1300 +const GIZMO_HIT_RENDER_ORDER = GIZMO_RENDER_ORDER + 1 +const PLANE_NORMAL: Record = { + xy: 'z', + xz: 'y', + yz: 'x', +} +const COMPONENT_ACTIVE_COLOR = '#ff9a24' +const COMPONENT_SELECTED_COLOR = '#ff6d00' +const COMPONENT_HOVER_COLOR = '#ffb020' +const COMPONENT_IDLE_COLOR = '#737982' +const DEFAULT_BEVEL_SEGMENTS = 6 +const ROTATION_SNAP_ANGLE_DEGREES = 15 +const EMPTY_COMPONENT_IDS: string[] = [] + +const FLOATING_PANEL_CLASS = + 'pointer-events-auto flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur-md' +const TOOLBAR_POPOVER_CLASS = + 'absolute top-[calc(100%+10px)] left-1/2 z-50 w-72 -translate-x-1/2 rounded-xl border border-border/50 bg-background/98 p-2 shadow-elevation-4 backdrop-blur-xl' +const OPERATION_INPUT_CLASS = + 'h-6 w-12 rounded-md border border-border/50 bg-accent/25 px-1 text-right font-mono text-[10px] text-foreground tabular-nums outline-none hover:border-border/80 focus:border-ring disabled:opacity-35' + +const playCustomMeshSfx = (action: CustomMeshSfxAction) => triggerSFX(customMeshSfx(action)) + +function preferredFace(topology: CustomMeshTopology): CustomMeshFace | null { + return ( + topology.faces + .map((face) => ({ + face, + normal: customMeshFaceNormal(topology, face), + centroid: customMeshFaceCentroid(topology, face), + })) + .filter((entry) => entry.normal && entry.centroid) + .sort((a, b) => b.normal![1] - a.normal![1] || b.centroid![1] - a.centroid![1])[0]?.face ?? + null + ) +} + +function topologyVertexMap(topology: CustomMeshTopology): Map { + return new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) +} + +function selectionCentroid( + topology: CustomMeshTopology, + selection: CustomMeshSelection, +): Point | null { + const ids = customMeshSelectionVertexIds(topology, selection) + const positions = topology.vertices + .filter((vertex) => ids.has(vertex.id)) + .map((vertex) => vertex.position) + if (positions.length === 0) return null + const total = positions.reduce( + (sum, point) => [sum[0] + point[0], sum[1] + point[1], sum[2] + point[2]], + [0, 0, 0], + ) + return [total[0] / positions.length, total[1] / positions.length, total[2] / positions.length] +} + +function topologyExtent(topology: CustomMeshTopology): number { + const axes = [0, 1, 2] as const + return Math.max( + 0.5, + ...axes.map((axis) => { + const values = topology.vertices.map((vertex) => vertex.position[axis]) + return Math.max(...values) - Math.min(...values) + }), + ) +} + +function closestAxisParameterToRay( + axisOrigin: Vector3, + axisDirection: Vector3, + ray: Raycaster['ray'], +): number { + const originToRay = axisOrigin.clone().sub(ray.origin) + const b = axisDirection.dot(ray.direction) + const d = axisDirection.dot(originToRay) + const e = ray.direction.dot(originToRay) + const denominator = 1 - b * b + if (Math.abs(denominator) < 1e-6) return -d + const axisParameter = (b * e - d) / denominator + return e + b * axisParameter < 0 ? -d : axisParameter +} + +function VertexHandle({ + id, + position, + radius, + selected, + active, + xray, + onSelect, +}: { + id: string + position: Point + radius: number + selected: boolean + active: boolean + xray: boolean + onSelect: (id: string, additive: boolean, event: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const visibleGeometry = useMemo(() => new SphereGeometry(radius, 16, 12), [radius]) + const hitGeometry = useMemo(() => new SphereGeometry(radius * 4.2, 12, 8), [radius]) + const visibleMaterial = useMemo( + () => new MeshBasicNodeMaterial({ depthTest: !xray, depthWrite: false }), + [xray], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: '#ffffff', + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [], + ) + useEffect(() => { + visibleMaterial.color.set( + active + ? COMPONENT_ACTIVE_COLOR + : selected + ? COMPONENT_SELECTED_COLOR + : hovered + ? COMPONENT_HOVER_COLOR + : COMPONENT_IDLE_COLOR, + ) + }, [active, hovered, selected, visibleMaterial]) + useEffect( + () => () => { + visibleGeometry.dispose() + hitGeometry.dispose() + visibleMaterial.dispose() + hitMaterial.dispose() + }, + [hitGeometry, hitMaterial, visibleGeometry, visibleMaterial], + ) + + return ( + + {}} + renderOrder={1200} + /> + { + event.stopPropagation() + onSelect(id, event.nativeEvent.shiftKey, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'pointer' + }} + onPointerLeave={() => { + setHovered(false) + if (document.body.style.cursor === 'pointer') document.body.style.cursor = '' + }} + renderOrder={1201} + /> + + ) +} + +function EdgeHandle({ + id, + start, + end, + radius, + selected, + active, + xray, + onSelect, + onPointerDown, +}: { + id: string + start: Point + end: Point + radius: number + selected: boolean + active: boolean + xray: boolean + onSelect: (id: string, additive: boolean, event: ThreeEvent) => void + onPointerDown?: (id: string, event: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const hoverCursor = onPointerDown ? 'ew-resize' : 'pointer' + const placement = useMemo(() => { + const a = new Vector3(...start) + const b = new Vector3(...end) + const direction = b.clone().sub(a) + const length = direction.length() + return { + length, + position: a.add(b).multiplyScalar(0.5), + quaternion: new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction.normalize()), + } + }, [end, start]) + const visibleGeometry = useMemo(() => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([...start, ...end], 3)) + return geometry + }, [end, start]) + const hitGeometry = useMemo( + () => new CylinderGeometry(radius * 3.2, radius * 3.2, placement.length, 8), + [placement.length, radius], + ) + const emphasisGeometry = useMemo( + () => new CylinderGeometry(radius * 1.35, radius * 1.35, placement.length, 12), + [placement.length, radius], + ) + const visibleMaterial = useMemo( + () => + new LineBasicNodeMaterial({ + transparent: true, + depthTest: !xray, + depthWrite: false, + }), + [xray], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: '#ffffff', + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [], + ) + const emphasisMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + transparent: true, + depthTest: !xray, + depthWrite: false, + }), + [xray], + ) + useEffect(() => { + const color = active + ? COMPONENT_ACTIVE_COLOR + : selected + ? COMPONENT_SELECTED_COLOR + : hovered + ? COMPONENT_HOVER_COLOR + : COMPONENT_IDLE_COLOR + visibleMaterial.color.set(color) + visibleMaterial.opacity = active || selected || hovered ? 1 : 0.65 + emphasisMaterial.color.set(color) + emphasisMaterial.opacity = active ? 1 : selected ? 0.96 : hovered ? 0.82 : 0 + }, [active, emphasisMaterial, hovered, selected, visibleMaterial]) + const visibleLine = useMemo(() => { + const line = new LineSegments(visibleGeometry, visibleMaterial) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.raycast = () => {} + line.renderOrder = 1200 + return line + }, [visibleGeometry, visibleMaterial]) + useEffect( + () => () => { + visibleGeometry.dispose() + hitGeometry.dispose() + emphasisGeometry.dispose() + visibleMaterial.dispose() + hitMaterial.dispose() + emphasisMaterial.dispose() + }, + [ + emphasisGeometry, + emphasisMaterial, + hitGeometry, + hitMaterial, + visibleGeometry, + visibleMaterial, + ], + ) + + return ( + <> + + + {}} + renderOrder={1202} + visible={active || selected || hovered} + /> + { + event.stopPropagation() + if (onPointerDown) return + onSelect(id, event.nativeEvent.shiftKey, event) + }} + onPointerDown={ + onPointerDown + ? (event) => { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onPointerDown(id, event) + } + : undefined + } + onPointerEnter={(event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = hoverCursor + }} + onPointerLeave={() => { + setHovered(false) + if (document.body.style.cursor === hoverCursor) document.body.style.cursor = '' + }} + renderOrder={1201} + /> + + + ) +} + +function FaceHandle({ + face, + topology, + selected, + active, + xray, + interactive = true, + onSelect, +}: { + face: CustomMeshFace + topology: CustomMeshTopology + selected: boolean + active: boolean + xray: boolean + interactive?: boolean + onSelect: (id: string, additive: boolean, event: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const geometries = useMemo(() => { + const triangulated = triangulateCustomMeshFace(topology, face) + if (!triangulated) return null + const fill = new BufferGeometry() + fill.setAttribute( + 'position', + new Float32BufferAttribute( + triangulated.triangles.flatMap((triangle) => triangle.flat()), + 3, + ), + ) + const vertexById = topologyVertexMap(topology) + const outlinePositions: number[] = [] + for (let index = 0; index < face.vertexIds.length; index += 1) { + const start = vertexById.get(face.vertexIds[index]!) + const end = vertexById.get(face.vertexIds[(index + 1) % face.vertexIds.length]!) + if (start && end) outlinePositions.push(...start, ...end) + } + const outline = new BufferGeometry() + outline.setAttribute('position', new Float32BufferAttribute(outlinePositions, 3)) + return { fill, outline } + }, [face, topology]) + const fillMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + transparent: true, + depthTest: !xray, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -4, + side: DoubleSide, + }), + [xray], + ) + const outlineMaterial = useMemo( + () => + new LineBasicNodeMaterial({ + transparent: true, + depthTest: !xray, + depthWrite: false, + }), + [xray], + ) + useEffect(() => { + fillMaterial.color.set( + active + ? COMPONENT_ACTIVE_COLOR + : selected + ? COMPONENT_SELECTED_COLOR + : hovered + ? COMPONENT_HOVER_COLOR + : COMPONENT_IDLE_COLOR, + ) + fillMaterial.opacity = active ? 0.46 : selected ? 0.38 : hovered ? 0.18 : 0.001 + outlineMaterial.color.set( + active + ? COMPONENT_ACTIVE_COLOR + : selected + ? COMPONENT_SELECTED_COLOR + : hovered + ? COMPONENT_HOVER_COLOR + : COMPONENT_IDLE_COLOR, + ) + outlineMaterial.opacity = active || selected ? 1 : hovered ? 0.9 : 0.28 + }, [active, fillMaterial, hovered, outlineMaterial, selected]) + const outline = useMemo(() => { + if (!geometries) return null + const line = new LineSegments(geometries.outline, outlineMaterial) + line.layers.set(EDITOR_LAYER) + line.raycast = () => {} + line.renderOrder = 1201 + return line + }, [geometries, outlineMaterial]) + useEffect( + () => () => { + geometries?.fill.dispose() + geometries?.outline.dispose() + fillMaterial.dispose() + outlineMaterial.dispose() + }, + [fillMaterial, geometries, outlineMaterial], + ) + if (!geometries) return null + + return ( + + { + event.stopPropagation() + onSelect(face.id, event.nativeEvent.shiftKey, event) + } + : undefined + } + onPointerEnter={ + interactive + ? (event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'pointer' + } + : undefined + } + onPointerLeave={ + interactive + ? () => { + setHovered(false) + if (document.body.style.cursor === 'pointer') document.body.style.cursor = '' + } + : undefined + } + raycast={interactive ? undefined : () => {}} + renderOrder={1200} + /> + {outline ? : null} + + ) +} + +function AxisTransformHandle({ + axis, + length, + radius, + moveActive, + scaleActive, + onMovePointerDown, + onScalePointerDown, +}: { + axis: Axis + length: number + radius: number + moveActive: boolean + scaleActive: boolean + onMovePointerDown: (axis: Axis, event: ThreeEvent) => void + onScalePointerDown: (axis: Axis, event: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(null) + const shaftGeometry = useMemo( + () => new CylinderGeometry(radius * 0.35, radius * 0.35, length * 0.8, 10), + [length, radius], + ) + const arrowGeometry = useMemo( + () => new ConeGeometry(radius * 1.6, length * 0.2, 24), + [length, radius], + ) + const moveHitGeometry = useMemo( + () => new CylinderGeometry(radius * 4.5, radius * 4.5, length, 8), + [length, radius], + ) + const scaleGeometry = useMemo(() => new SphereGeometry(radius * 1.3, 12, 12), [radius]) + const scaleHitGeometry = useMemo(() => new SphereGeometry(radius * 4.2, 12, 8), [radius]) + const moveMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + transparent: true, + opacity: 1, + depthTest: false, + depthWrite: false, + }), + [], + ) + const scaleMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + transparent: true, + opacity: 1, + depthTest: false, + depthWrite: false, + }), + [], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: AXIS_COLORS[axis], + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [axis], + ) + useEffect(() => { + moveMaterial.color.set( + moveActive || hovered === 'translate' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], + ) + scaleMaterial.color.set( + scaleActive || hovered === 'scale' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], + ) + }, [axis, hovered, moveActive, moveMaterial, scaleActive, scaleMaterial]) + useEffect( + () => () => { + shaftGeometry.dispose() + arrowGeometry.dispose() + moveHitGeometry.dispose() + scaleGeometry.dispose() + scaleHitGeometry.dispose() + moveMaterial.dispose() + scaleMaterial.dispose() + hitMaterial.dispose() + }, + [ + arrowGeometry, + hitMaterial, + moveHitGeometry, + moveMaterial, + scaleGeometry, + scaleHitGeometry, + scaleMaterial, + shaftGeometry, + ], + ) + const rotation: Point = + axis === 'x' ? [0, 0, -Math.PI / 2] : axis === 'z' ? [Math.PI / 2, 0, 0] : [0, 0, 0] + const scalePosition = length * 1.2 + + return ( + + {}} + renderOrder={GIZMO_RENDER_ORDER} + /> + {}} + renderOrder={GIZMO_RENDER_ORDER} + /> + { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onMovePointerDown(axis, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered('translate') + document.body.style.cursor = 'grab' + }} + onPointerLeave={() => { + setHovered(null) + if (document.body.style.cursor === 'grab') document.body.style.cursor = '' + }} + position={[0, length * 0.5, 0]} + renderOrder={GIZMO_HIT_RENDER_ORDER} + /> + {}} + renderOrder={GIZMO_RENDER_ORDER} + /> + { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onScalePointerDown(axis, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered('scale') + document.body.style.cursor = 'grab' + }} + onPointerLeave={() => { + setHovered(null) + if (document.body.style.cursor === 'grab') document.body.style.cursor = '' + }} + position={[0, scalePosition, 0]} + renderOrder={GIZMO_HIT_RENDER_ORDER} + /> + + ) +} + +function PlaneMoveHandle({ + plane, + offset, + size, + active, + onPointerDown, +}: { + plane: PlaneAxes + offset: number + size: number + active: boolean + onPointerDown: (axis: Axis, event: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const geometry = useMemo(() => new PlaneGeometry(size, size), [size]) + const hitGeometry = useMemo(() => new PlaneGeometry(size * 1.45, size * 1.45), [size]) + const normalAxis = PLANE_NORMAL[plane] + const material = useMemo( + () => + new MeshBasicNodeMaterial({ + color: AXIS_COLORS[normalAxis], + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + opacity: 1, + }), + [normalAxis], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: '#ffffff', + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + opacity: 0, + }), + [], + ) + useEffect(() => { + material.color.set(active || hovered ? PIVOT_HOVERED_COLOR : AXIS_COLORS[normalAxis]) + }, [active, hovered, material, normalAxis]) + useEffect( + () => () => { + geometry.dispose() + hitGeometry.dispose() + material.dispose() + hitMaterial.dispose() + }, + [geometry, hitGeometry, hitMaterial, material], + ) + const position: Point = + plane === 'xy' + ? [offset, offset, 0] + : plane === 'xz' + ? [offset, 0, offset] + : [0, offset, offset] + const rotation: Point = + plane === 'xz' ? [-Math.PI / 2, 0, 0] : plane === 'yz' ? [0, Math.PI / 2, 0] : [0, 0, 0] + + return ( + + {}} + renderOrder={GIZMO_RENDER_ORDER} + /> + { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onPointerDown(normalAxis, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'move' + }} + onPointerLeave={() => { + setHovered(false) + if (document.body.style.cursor === 'move') document.body.style.cursor = '' + }} + renderOrder={GIZMO_HIT_RENDER_ORDER} + /> + + ) +} + +function RotationHandle({ + axis, + radius, + tube, + active, + onPointerDown, +}: { + axis: Axis + radius: number + tube: number + active: boolean + onPointerDown: (axis: Axis, event: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const ringGeometry = useMemo( + () => new TorusGeometry(radius, tube * 0.35, 8, 32, Math.PI / 2), + [radius, tube], + ) + const hitGeometry = useMemo( + () => new TorusGeometry(radius, tube * 4.5, 8, 32, Math.PI / 2), + [radius, tube], + ) + const material = useMemo( + () => + new MeshBasicNodeMaterial({ + transparent: true, + opacity: 1, + depthTest: false, + depthWrite: false, + }), + [], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: AXIS_COLORS[axis], + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [axis], + ) + useEffect(() => { + material.color.set(active || hovered ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis]) + }, [active, axis, hovered, material]) + useEffect( + () => () => { + ringGeometry.dispose() + hitGeometry.dispose() + material.dispose() + hitMaterial.dispose() + }, + [hitGeometry, hitMaterial, material, ringGeometry], + ) + const rotation: Point = + axis === 'x' ? [0, -Math.PI / 2, 0] : axis === 'y' ? [Math.PI / 2, 0, 0] : [0, 0, 0] + + return ( + + {}} + renderOrder={GIZMO_RENDER_ORDER} + /> + { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onPointerDown(axis, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={() => { + setHovered(false) + if (document.body.style.cursor === 'grab') document.body.style.cursor = '' + }} + renderOrder={GIZMO_HIT_RENDER_ORDER} + /> + + ) +} + +function LoopCutTarget({ + edgeId, + start, + end, + radius, + onHover, + onPointerDown, +}: { + edgeId: string + start: Point + end: Point + radius: number + onHover: (edgeId: string | null) => void + onPointerDown: (edgeId: string, event: ThreeEvent) => void +}) { + const placement = useMemo(() => { + const from = new Vector3(...start) + const to = new Vector3(...end) + const direction = to.clone().sub(from) + return { + length: direction.length(), + position: from.add(to).multiplyScalar(0.5), + quaternion: new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction.normalize()), + } + }, [end, start]) + const geometry = useMemo( + () => new CylinderGeometry(radius, radius, placement.length, 8), + [placement.length, radius], + ) + const material = useMemo( + () => + new MeshBasicNodeMaterial({ + color: '#ffffff', + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [], + ) + useEffect( + () => () => { + geometry.dispose() + material.dispose() + if (document.body.style.cursor === 'crosshair') document.body.style.cursor = '' + }, + [geometry, material], + ) + + return ( + + { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onPointerDown(edgeId, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + onHover(edgeId) + document.body.style.cursor = 'crosshair' + }} + onPointerLeave={() => { + onHover(null) + if (document.body.style.cursor === 'crosshair') document.body.style.cursor = '' + }} + renderOrder={1221} + /> + + ) +} + +function LoopCutPreview({ segments }: { segments: [Point, Point][] }) { + const geometry = useMemo(() => { + const next = new BufferGeometry() + next.setAttribute( + 'position', + new Float32BufferAttribute( + segments.flatMap(([from, to]) => [...from, ...to]), + 3, + ), + ) + return next + }, [segments]) + const material = useMemo( + () => + new LineBasicNodeMaterial({ + color: '#facc15', + depthTest: false, + depthWrite: false, + }), + [], + ) + const line = useMemo(() => { + const next = new LineSegments(geometry, material) + next.frustumCulled = false + next.layers.set(EDITOR_LAYER) + next.raycast = () => {} + next.renderOrder = 1220 + return next + }, [geometry, material]) + useEffect( + () => () => { + geometry.dispose() + material.dispose() + }, + [geometry, material], + ) + return +} + +function ToolbarButton({ + label, + active = false, + disabled = false, + destructive = false, + sound = 'tool-select', + onClick, + children, +}: { + label: string + active?: boolean + disabled?: boolean + destructive?: boolean + sound?: CustomMeshSfxAction | false + onClick?: () => void + children: ReactNode +}) { + return ( + + + + + ) +} + +function ToolbarMenuItem({ + label, + shortcut, + active = false, + disabled = false, + destructive = false, + sound = 'tool-select', + onClick, + children, +}: { + label: string + shortcut?: string + active?: boolean + disabled?: boolean + destructive?: boolean + sound?: CustomMeshSfxAction | false + onClick: () => void + children: ReactNode +}) { + return ( + + ) +} + +function ToolbarOperationItem({ + label, + shortcut, + active = false, + disabled = false, + controls, + onClick, + children, +}: { + label: string + shortcut?: string + active?: boolean + disabled?: boolean + controls?: ReactNode + onClick: () => void + children: ReactNode +}) { + return ( +
+ + {controls ?
{controls}
: null} +
+ ) +} + +function ToolbarPanelFrame({ + label, + className, + children, +}: { + label: string + className?: string + children: ReactNode +}) { + return ( +
+ {children} +
+ ) +} + +function CustomMeshEditor({ + node, + target, + mirrorTarget, +}: { + node: CustomMeshNode + target: Object3D + mirrorTarget: boolean +}) { + const { camera, gl } = useThree() + const outerRef = useRef(null) + const menuScaleRef = useRef(null) + const menuWorldPositionRef = useRef(new Vector3()) + const editing = useInteractionScope( + (state) => state.scope.kind === 'mesh-editing' && state.scope.nodeId === node.id, + ) + const mode = useCustomMeshEditSession((state) => + state.nodeId === node.id ? state.selection.mode : 'face', + ) + const selectedIds = useCustomMeshEditSession((state) => + state.nodeId === node.id ? state.selection.ids : EMPTY_COMPONENT_IDS, + ) + const activeId = useCustomMeshEditSession((state) => + state.nodeId === node.id ? state.selection.activeId : null, + ) + const [transformTool, setTransformTool] = useState('transform') + const [xray, setXray] = useState(false) + const [previewTopology, setPreviewTopology] = useState(null) + const [activeTransform, setActiveTransform] = useState(null) + const [loopCutSegments, setLoopCutSegments] = useState<[Point, Point][] | null>(null) + const [loopCutEdgeId, setLoopCutEdgeId] = useState(null) + const [loopCutSliding, setLoopCutSliding] = useState(false) + const [loopCutCount, setLoopCutCount] = useState(1) + const [loopCutFactor, setLoopCutFactor] = useState(0.5) + const [extrudeDistance, setExtrudeDistance] = useState('0.25') + const [insetAmount, setInsetAmount] = useState('0.15') + const [bevelSegments, setBevelSegments] = useState(DEFAULT_BEVEL_SEGMENTS) + const [toolbarPanel, setToolbarPanel] = useState(null) + const [error, setError] = useState(null) + const cancelDragRef = useRef<(() => void) | null>(null) + const displayTopology = previewTopology ?? node.topology + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) + const selection = useMemo( + () => ({ mode, ids: selectedIds }), + [mode, selectedIds], + ) + const extent = topologyExtent(displayTopology) + const componentRadius = Math.min(0.055, Math.max(0.022, extent * 0.011)) + const gizmoOrigin = selectionCentroid(displayTopology, selection) + const gizmoLength = Math.min(1.15, Math.max(0.42, extent * 0.29)) + const gizmoRadius = Math.min(0.04, Math.max(0.014, extent * 0.009)) + const rotationGizmoRadius = gizmoLength * 0.65 + const planeHandleSize = gizmoLength * 0.2 + const planeHandleOffset = gizmoLength * 0.25 + const vertexById = useMemo(() => topologyVertexMap(displayTopology), [displayTopology]) + const menuAnchor = useMemo(() => { + const xs = displayTopology.vertices.map((vertex) => vertex.position[0]) + const ys = displayTopology.vertices.map((vertex) => vertex.position[1]) + const zs = displayTopology.vertices.map((vertex) => vertex.position[2]) + return [ + (Math.min(...xs) + Math.max(...xs)) / 2, + Math.max(...ys) + Math.min(1.4, Math.max(0.9, extent * 0.25)), + (Math.min(...zs) + Math.max(...zs)) / 2, + ] + }, [displayTopology, extent]) + + useFrame((state) => { + const outer = outerRef.current + if (!outer) return + if (mirrorTarget) { + outer.position.copy(target.position) + outer.quaternion.copy(target.quaternion) + outer.scale.copy(target.scale) + } + if (menuScaleRef.current) { + const menuWorldPosition = menuWorldPositionRef.current.set(...menuAnchor) + outer.localToWorld(menuWorldPosition) + menuScaleRef.current.style.transform = `scale(${getFloatingMenuScale( + state.camera, + menuWorldPosition, + )})` + } + }) + + const ownsEditSession = useCallback(() => { + const scope = useInteractionScope.getState().scope + return scope.kind === 'mesh-editing' && scope.nodeId === node.id + }, [node.id]) + + const endOwnedScope = useCallback(() => { + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'mesh-editing' && scope.nodeId === node.id) + }, [node.id]) + + const exitEditMode = useCallback(() => { + cancelDragRef.current?.() + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + endOwnedScope() + useCustomMeshEditSession.getState().end(node.id) + setPreviewTopology(null) + setTransformTool('transform') + setActiveTransform(null) + setLoopCutSegments(null) + setLoopCutEdgeId(null) + setLoopCutSliding(false) + setToolbarPanel(null) + setError(null) + playCustomMeshSfx('finish') + }, [endOwnedScope, node.id]) + + useEffect( + () => () => { + cancelDragRef.current?.() + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + endOwnedScope() + useCustomMeshEditSession.getState().end(node.id) + if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' + }, + [endOwnedScope, node.id], + ) + + useEffect(() => { + if (editing) return + cancelDragRef.current?.() + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + setPreviewTopology(null) + setToolbarPanel(null) + setLoopCutSegments(null) + setLoopCutEdgeId(null) + setLoopCutSliding(false) + setActiveTransform(null) + useCustomMeshEditSession.getState().end(node.id) + }, [editing, node.id]) + + useEffect(() => { + if (!editing) return + const onToolCancel = () => { + markToolCancelConsumed() + if (toolbarPanel) { + setToolbarPanel(null) + playCustomMeshSfx('cancel') + } else if (cancelDragRef.current) cancelDragRef.current() + else if (transformTool === 'loop-cut') { + setTransformTool('transform') + setLoopCutEdgeId(null) + setLoopCutSegments(null) + setError(null) + playCustomMeshSfx('cancel') + } else exitEditMode() + } + emitter.on('tool:cancel', onToolCancel) + return () => emitter.off('tool:cancel', onToolCancel) + }, [editing, exitEditMode, toolbarPanel, transformTool]) + + useEffect(() => { + if (!(editing && toolbarPanel)) return + const closePanel = (event: PointerEvent) => { + const targetElement = event.target + if (targetElement instanceof Node && menuScaleRef.current?.contains(targetElement)) return + setToolbarPanel(null) + } + window.addEventListener('pointerdown', closePanel, true) + return () => window.removeEventListener('pointerdown', closePanel, true) + }, [editing, toolbarPanel]) + + useEffect(() => { + if (!editing) return + const onGridClick = () => { + const scope = useInteractionScope.getState().scope + if (scope.kind !== 'mesh-editing' || scope.nodeId !== node.id || cancelDragRef.current) return + useCustomMeshEditSession.getState().setSelection(node.id, { mode, ids: [], activeId: null }) + setError(null) + playCustomMeshSfx('component-select') + } + emitter.on('grid:click', onGridClick) + return () => emitter.off('grid:click', onGridClick) + }, [editing, mode, node.id]) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const element = event.target as HTMLElement | null + if ( + element?.tagName === 'INPUT' || + element?.tagName === 'TEXTAREA' || + element?.isContentEditable + ) + return + if (event.key === 'Tab') { + event.preventDefault() + event.stopImmediatePropagation() + if (cancelDragRef.current) return + if (editing) { + exitEditMode() + } else if (useInteractionScope.getState().scope.kind === 'idle') { + const face = preferredFace(node.topology) + useCustomMeshEditSession.getState().begin(node.id, { + mode: 'face', + ids: face ? [face.id] : [], + activeId: face?.id ?? null, + }) + setTransformTool('transform') + setToolbarPanel(null) + setError(null) + useInteractionScope.getState().begin(meshEditScope(node.id)) + triggerSFX('sfx:item-pick') + } + return + } + if (!editing) return + const nextMode = + event.key === '1' + ? 'vertex' + : event.key === '2' + ? 'edge' + : event.key === '3' + ? 'face' + : null + if (!nextMode || cancelDragRef.current) return + event.preventDefault() + event.stopImmediatePropagation() + const converted = convertCustomMeshSelection( + node.topology, + { + mode, + ids: selectedIds, + activeId, + }, + nextMode, + ) + useCustomMeshEditSession.getState().setSelection(node.id, converted) + setError(null) + playCustomMeshSfx('tool-select') + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [activeId, editing, exitEditMode, mode, node.id, node.topology, selectedIds]) + + useEffect(() => { + useCustomMeshEditSession.getState().reconcileSelection(node.id, node.topology) + }, [node.id, node.topology]) + + const enterEditMode = (event: ReactMouseEvent) => { + event.stopPropagation() + const face = preferredFace(node.topology) + useCustomMeshEditSession.getState().begin(node.id, { + mode: 'face', + ids: face ? [face.id] : [], + activeId: face?.id ?? null, + }) + setTransformTool('transform') + setToolbarPanel(null) + setError(null) + useInteractionScope.getState().begin(meshEditScope(node.id)) + triggerSFX('sfx:item-pick') + } + + const componentIsVisible = useCallback( + (id: string, event: ThreeEvent) => { + if (xray) return true + target.updateWorldMatrix(true, true) + const raycaster = new Raycaster() + raycaster.ray.copy(event.ray) + const nearestSurface = raycaster.intersectObject(target, true)[0] + if (!nearestSurface) return true + let worldPoint: Vector3 | null = null + if (mode === 'vertex') { + const vertex = displayTopology.vertices.find((entry) => entry.id === id) + if (vertex) worldPoint = target.localToWorld(new Vector3(...vertex.position)) + } else if (mode === 'edge') { + const edge = displayTopology.edges.find((entry) => entry.id === id) + const vertices = topologyVertexMap(displayTopology) + const start = edge ? vertices.get(edge.vertexIds[0]) : null + const end = edge ? vertices.get(edge.vertexIds[1]) : null + if (start && end) { + const worldStart = target.localToWorld(new Vector3(...start)) + const worldEnd = target.localToWorld(new Vector3(...end)) + worldPoint = new Vector3() + event.ray.distanceSqToSegment(worldStart, worldEnd, undefined, worldPoint) + } + } else { + worldPoint = event.point.clone() + } + if (!worldPoint) return false + const scale = target.getWorldScale(new Vector3()) + const tolerance = componentRadius * Math.max(scale.x, scale.y, scale.z) * 1.5 + return event.ray.origin.distanceTo(worldPoint) <= nearestSurface.distance + tolerance + }, + [componentRadius, displayTopology, mode, target, xray], + ) + + const selectComponent = useCallback( + (id: string, additive: boolean, event: ThreeEvent) => { + if (!componentIsVisible(id, event)) return + const next = selectCustomMeshComponent({ mode, ids: selectedIds, activeId }, id, additive) + useCustomMeshEditSession.getState().setSelection(node.id, next) + setError(null) + playCustomMeshSfx('component-select') + }, + [activeId, componentIsVisible, mode, node.id, selectedIds], + ) + + const switchMode = (nextMode: ComponentMode) => { + if (cancelDragRef.current) return + const converted = convertCustomMeshSelection( + displayTopology, + { mode, ids: selectedIds, activeId }, + nextMode, + ) + useCustomMeshEditSession.getState().setSelection(node.id, converted) + setToolbarPanel(null) + setError(null) + } + + const makeRay = useCallback( + (clientX: number, clientY: number) => { + const rect = gl.domElement.getBoundingClientRect() + const pointer = new Vector2( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + const raycaster = new Raycaster() + raycaster.setFromCamera(pointer, camera) + return raycaster.ray + }, + [camera, gl.domElement], + ) + + const beginTranslationDrag = useCallback( + (axis: Axis, event: ThreeEvent) => { + if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return + const origin = selectionCentroid(displayTopology, selection) + if (!origin) return + target.updateWorldMatrix(true, false) + const originLocal = new Vector3(...origin) + const worldOrigin = target.localToWorld(originLocal.clone()) + const localAxis = new Vector3(...AXIS_VECTORS[axis]) + const worldAxis = target + .localToWorld(originLocal.clone().add(localAxis)) + .sub(worldOrigin) + .normalize() + const initialParameter = closestAxisParameterToRay(worldOrigin, worldAxis, event.ray) + const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 + const baseTopology = displayTopology + const baseSelection = selection + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let latestTopology: CustomMeshTopology | null = null + let latestDelta: Point = [0, 0, 0] + let lastSnapDelta: string | null = null + let finished = false + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'translate')) + playCustomMeshSfx('drag-start') + useViewer.getState().setInputDragging(true) + setActiveTransform({ operation: 'translate', constraint: axis }) + document.body.style.cursor = 'grabbing' + + const onMove = (pointerEvent: PointerEvent) => { + const ray = makeRay(pointerEvent.clientX, pointerEvent.clientY) + const delta: Point = [0, 0, 0] + const parameter = closestAxisParameterToRay(worldOrigin, worldAxis, ray) + const worldPoint = worldOrigin + .clone() + .addScaledVector(worldAxis, parameter - initialParameter) + const localPoint = target.worldToLocal(worldPoint) + delta[axisIndex] = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) + const snapping = isGridSnapActive() && !pointerEvent.altKey + if (snapping) { + const step = useEditor.getState().gridSnapStep + if (step > 0) { + delta[axisIndex] = Math.round(delta[axisIndex] / step) * step + } + } + const snapDelta = delta.join(':') + const magnitude = Math.hypot(...delta) + if (snapping && magnitude > 1e-6 && snapDelta !== lastSnapDelta) { + lastSnapDelta = snapDelta + playCustomMeshSfx('move-step') + } else if (!snapping) { + lastSnapDelta = null + } + const result = applyCustomMeshCommand(baseTopology, { + type: 'translate-components', + selection: baseSelection, + delta, + }) + if (!result.ok) { + setError(result.error) + return + } + latestDelta = delta + latestTopology = result.topology + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setActiveTransform(null) + if (commit && latestTopology && Math.hypot(...latestDelta) > 1e-6) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + playCustomMeshSfx('finish') + } else if (!commit) { + playCustomMeshSfx('cancel') + } + if (ownsEditSession()) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onPointerUp = () => finish(true) + const onPointerCancel = () => finish(false) + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + }, + [displayTopology, makeRay, node.id, ownsEditSession, selectedIds.length, selection, target], + ) + + const beginRotationDrag = useCallback( + (axis: Axis, event: ThreeEvent) => { + if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return + const origin = selectionCentroid(displayTopology, selection) + if (!origin) return + target.updateWorldMatrix(true, false) + const originLocal = new Vector3(...origin) + const worldOrigin = target.localToWorld(originLocal.clone()) + const localAxis = new Vector3(...AXIS_VECTORS[axis]) + const worldAxis = target + .localToWorld(originLocal.clone().add(localAxis)) + .sub(worldOrigin) + .normalize() + const initialVector = event.point + .clone() + .sub(worldOrigin) + .projectOnPlane(worldAxis) + .normalize() + if (initialVector.lengthSq() < 1e-6) return + const rotationPlane = new Plane().setFromNormalAndCoplanarPoint(worldAxis, worldOrigin) + const baseTopology = displayTopology + const baseSelection = selection + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let previousWrappedAngle = 0 + let accumulatedAngle = 0 + let latestAngle = 0 + let lastSnapAngle: number | null = null + let latestTopology: CustomMeshTopology | null = null + let finished = false + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'rotate')) + playCustomMeshSfx('drag-start') + useViewer.getState().setInputDragging(true) + setActiveTransform({ operation: 'rotate', constraint: axis }) + document.body.style.cursor = 'grabbing' + + const onMove = (pointerEvent: PointerEvent) => { + const hit = makeRay(pointerEvent.clientX, pointerEvent.clientY).intersectPlane( + rotationPlane, + new Vector3(), + ) + if (!hit) return + const currentVector = hit.sub(worldOrigin).projectOnPlane(worldAxis) + if (currentVector.lengthSq() < 1e-6) return + currentVector.normalize() + const wrappedAngle = signedAngleAroundAxis(initialVector, currentVector, worldAxis) + accumulatedAngle += unwrapRotationDelta(previousWrappedAngle, wrappedAngle) + previousWrappedAngle = wrappedAngle + let angle = accumulatedAngle + const snapping = !pointerEvent.altKey && isAngleSnapActive() + if (snapping) { + const step = (ROTATION_SNAP_ANGLE_DEGREES * Math.PI) / 180 + angle = Math.round(angle / step) * step + } + if (snapping && Math.abs(angle) > 1e-6 && angle !== lastSnapAngle) { + lastSnapAngle = angle + playCustomMeshSfx('rotate-step') + } else if (!snapping) { + lastSnapAngle = null + } + const result = applyCustomMeshCommand(baseTopology, { + type: 'rotate-components', + selection: baseSelection, + pivot: origin, + axis: AXIS_VECTORS[axis], + angle, + }) + if (!result.ok) { + setError(result.error) + return + } + latestAngle = angle + latestTopology = result.topology + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setActiveTransform(null) + if (commit && latestTopology && Math.abs(latestAngle) > 1e-6) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + playCustomMeshSfx('finish') + } else if (!commit) { + playCustomMeshSfx('cancel') + } + if (ownsEditSession()) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onPointerUp = () => finish(true) + const onPointerCancel = () => finish(false) + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + }, + [displayTopology, makeRay, node.id, ownsEditSession, selectedIds.length, selection, target], + ) + + const beginScaleDrag = useCallback( + (axis: Axis, event: ThreeEvent) => { + if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return + const origin = selectionCentroid(displayTopology, selection) + if (!origin) return + target.updateWorldMatrix(true, false) + const originLocal = new Vector3(...origin) + const worldOrigin = target.localToWorld(originLocal.clone()) + const localAxis = new Vector3(...AXIS_VECTORS[axis]) + const worldAxis = target + .localToWorld(originLocal.clone().add(localAxis)) + .sub(worldOrigin) + .normalize() + const initialParameter = closestAxisParameterToRay(worldOrigin, worldAxis, event.ray) + const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 + const baseTopology = displayTopology + const baseSelection = selection + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let latestFactor = 1 + let lastSnapFactor: number | null = null + let latestTopology: CustomMeshTopology | null = null + let finished = false + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'scale')) + playCustomMeshSfx('drag-start') + useViewer.getState().setInputDragging(true) + setActiveTransform({ operation: 'scale', constraint: axis }) + document.body.style.cursor = 'grabbing' + + const onMove = (pointerEvent: PointerEvent) => { + const parameter = closestAxisParameterToRay( + worldOrigin, + worldAxis, + makeRay(pointerEvent.clientX, pointerEvent.clientY), + ) + const worldPoint = worldOrigin + .clone() + .addScaledVector(worldAxis, parameter - initialParameter) + const localPoint = target.worldToLocal(worldPoint) + const distance = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) + const snapStep = !pointerEvent.altKey && isGridSnapActive() ? 0.1 : 0 + const factor = customMeshScaleFactorFromDrag(distance, gizmoLength, snapStep) + if (snapStep > 0 && Math.abs(factor - 1) > 1e-6 && factor !== lastSnapFactor) { + lastSnapFactor = factor + playCustomMeshSfx('resize-step') + } else if (snapStep === 0) { + lastSnapFactor = null + } + const result = applyCustomMeshCommand(baseTopology, { + type: 'scale-components', + selection: baseSelection, + pivot: origin, + factors: customMeshScaleFactors(axis, factor), + }) + if (!result.ok) { + setError(result.error) + return + } + latestFactor = factor + latestTopology = result.topology + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + setError(null) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setActiveTransform(null) + if (commit && latestTopology && Math.abs(latestFactor - 1) > 1e-6) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + playCustomMeshSfx('finish') + } else if (!commit) { + playCustomMeshSfx('cancel') + } + if (ownsEditSession()) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onPointerUp = () => finish(true) + const onPointerCancel = () => finish(false) + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + }, + [ + displayTopology, + gizmoLength, + makeRay, + node.id, + ownsEditSession, + selectedIds.length, + selection, + target, + ], + ) + + const beginBevelDrag = useCallback( + (edgeId: string, event: ThreeEvent) => { + if (event.nativeEvent.button !== 0 || !ownsEditSession() || cancelDragRef.current) return + if (!displayTopology.edges.some((edge) => edge.id === edgeId)) return + const baseTopology = displayTopology + const startClientX = event.nativeEvent.clientX + const startClientY = event.nativeEvent.clientY + const viewportHeight = gl.domElement.clientHeight + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let activeSegments = bevelSegments + let latestWidth = 0 + let lastWidthStep = 0 + let latestTopology: CustomMeshTopology | null = null + let latestSelection: CustomMeshSelection | null = null + let finished = false + + useCustomMeshEditSession.getState().setSelection(node.id, { + mode: 'edge', + ids: [edgeId], + activeId: edgeId, + }) + setToolbarPanel(null) + setError(null) + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'bevel')) + playCustomMeshSfx('operation-start') + useViewer.getState().setInputDragging(true) + document.body.style.cursor = 'ew-resize' + + const updatePreview = (width: number, segments = activeSegments) => { + if (width <= 1e-6) return false + const result = applyCustomMeshCommand(baseTopology, { + type: 'bevel-edge', + edgeId, + width, + segments, + profile: 0.5, + clampOverlap: true, + }) + if (!result.ok) { + setError(result.error) + return false + } + activeSegments = segments + latestWidth = width + latestTopology = result.topology + latestSelection = result.selection + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + setError(null) + return true + } + + const onMove = (pointerEvent: PointerEvent) => { + const deltaX = pointerEvent.clientX - startClientX + const deltaY = pointerEvent.clientY - startClientY + if (Math.hypot(deltaX, deltaY) < 2) return + const width = customMeshBevelWidthFromDrag(deltaX, deltaY, extent, viewportHeight) + const widthStep = Math.floor(width / Math.max(0.01, extent * 0.025)) + if (widthStep > 0 && widthStep !== lastWidthStep) { + lastWidthStep = widthStep + playCustomMeshSfx('resize-step') + } + updatePreview(width, activeSegments) + } + + const onWheel = (wheelEvent: WheelEvent) => { + const direction = consumeCustomMeshGestureWheel(wheelEvent) + if (direction === 0) return + const segments = Math.min(12, Math.max(1, activeSegments + direction)) + if (segments === activeSegments) return + activeSegments = segments + setBevelSegments(segments) + playCustomMeshSfx('resize-step') + if (latestWidth > 0) updatePreview(latestWidth, segments) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + if (commit && latestTopology && latestSelection && latestWidth > 1e-6) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + useCustomMeshEditSession.getState().setSelection(node.id, { + ...latestSelection, + activeId: latestSelection.ids.at(-1) ?? null, + }) + playCustomMeshSfx('operation-commit') + } else if (!commit) { + playCustomMeshSfx('cancel') + } + if (ownsEditSession()) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onPointerUp = () => finish(true) + const onPointerCancel = () => finish(false) + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + }, + [bevelSegments, displayTopology, extent, gl.domElement, node.id, ownsEditSession], + ) + + const previewLoopCut = useCallback((edgeId: string | null) => { + if (cancelDragRef.current) return + setLoopCutEdgeId(edgeId) + }, []) + + useEffect(() => { + if (!(editing && transformTool === 'loop-cut') || loopCutSliding) return + if (!loopCutEdgeId) { + setLoopCutSegments(null) + setError(null) + return + } + const segments = customMeshLoopCutSegments(node.topology, loopCutEdgeId, 0.5, loopCutCount) + setLoopCutSegments(segments) + setLoopCutFactor(0.5) + setError(segments ? null : 'Loop cut requires a connected ring of quad faces') + }, [editing, loopCutCount, loopCutEdgeId, loopCutSliding, node.topology, transformTool]) + + useEffect(() => { + if (transformTool === 'loop-cut' || loopCutSliding) return + setLoopCutEdgeId(null) + setLoopCutSegments(null) + setLoopCutFactor(0.5) + }, [loopCutSliding, transformTool]) + + useEffect(() => { + if (!(editing && transformTool === 'loop-cut' && !loopCutSliding)) return + const onWheel = (event: WheelEvent) => { + const direction = consumeCustomMeshGestureWheel(event) + if (direction === 0) return + setLoopCutCount((current) => { + const next = Math.min(32, Math.max(1, current + direction)) + if (next !== current) playCustomMeshSfx('resize-step') + return next + }) + } + const onPointerDown = (event: PointerEvent) => { + if (resolveLoopCutPointerAction('choosing-ring', event.button) !== 'cancel') return + event.preventDefault() + event.stopImmediatePropagation() + setTransformTool('transform') + setLoopCutEdgeId(null) + setLoopCutSegments(null) + setError(null) + playCustomMeshSfx('cancel') + swallowNextClick() + } + window.addEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + window.addEventListener('pointerdown', onPointerDown, true) + return () => { + window.removeEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + window.removeEventListener('pointerdown', onPointerDown, true) + } + }, [editing, loopCutSliding, transformTool]) + + const beginLoopCutSlide = useCallback( + (edgeId: string, event: ThreeEvent) => { + if ( + resolveLoopCutPointerAction('choosing-ring', event.nativeEvent.button) !== 'begin-slide' || + !ownsEditSession() || + cancelDragRef.current + ) + return + const edge = node.topology.edges.find((entry) => entry.id === edgeId) + const vertices = topologyVertexMap(node.topology) + const start = edge ? vertices.get(edge.vertexIds[0]) : null + const end = edge ? vertices.get(edge.vertexIds[1]) : null + if (!(edge && start && end)) return + target.updateWorldMatrix(true, false) + const worldStart = target.localToWorld(new Vector3(...start)) + const worldEnd = target.localToWorld(new Vector3(...end)) + const worldDirection = worldEnd.clone().sub(worldStart) + const worldLength = worldDirection.length() + if (worldLength < 1e-6) return + const worldAxis = worldDirection.normalize() + const initialParameter = closestAxisParameterToRay(worldStart, worldAxis, event.ray) + const baseTopology = node.topology + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let latestTopology: CustomMeshTopology | null = null + let latestSelection: CustomMeshSelection | null = null + let latestFactor = 0.5 + const activeCuts = loopCutCount + let lastSnapFactor: number | null = null + let finished = false + let confirmationAttached = false + + const updatePreview = (factor: number) => { + const effectiveFactor = resolveLoopCutSlideFactor(activeCuts, factor) + const result = applyCustomMeshCommand(baseTopology, { + type: 'loop-cut', + edgeId, + factor: effectiveFactor, + cuts: activeCuts, + }) + const segments = customMeshLoopCutSegments( + baseTopology, + edgeId, + effectiveFactor, + activeCuts, + ) + if (!result.ok || !segments) { + setError(result.ok ? 'Could not preview loop cut' : result.error) + return false + } + latestFactor = effectiveFactor + latestTopology = result.topology + latestSelection = result.selection + setPreviewTopology(result.topology) + setLoopCutSegments(segments) + setLoopCutFactor(effectiveFactor) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + setError(null) + return true + } + if (!updatePreview(0.5)) return + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'loop-cut')) + playCustomMeshSfx('operation-start') + useViewer.getState().setInputDragging(true) + setLoopCutSliding(true) + document.body.style.cursor = 'ew-resize' + + const onMove = (pointerEvent: PointerEvent) => { + if (activeCuts > 1) return + const parameter = closestAxisParameterToRay( + worldStart, + worldAxis, + makeRay(pointerEvent.clientX, pointerEvent.clientY), + ) + let factor = Math.min( + 0.98, + Math.max(0.02, 0.5 + (parameter - initialParameter) / worldLength), + ) + const snapping = isGridSnapActive() && !pointerEvent.altKey + if (snapping) { + const step = useEditor.getState().gridSnapStep + if (step > 0) + factor = Math.min( + 0.98, + Math.max(0.02, (Math.round((factor * worldLength) / step) * step) / worldLength), + ) + } + if (snapping && factor !== lastSnapFactor) { + lastSnapFactor = factor + playCustomMeshSfx('move-step') + } else if (!snapping) { + lastSnapFactor = null + } + updatePreview(factor) + } + + const finish = (outcome: 'commit-current' | 'commit-centered' | 'cancel') => { + if (finished) return + if (outcome === 'commit-centered' && !updatePreview(0.5)) outcome = 'cancel' + finished = true + window.removeEventListener('pointermove', onMove) + if (confirmationAttached) window.removeEventListener('pointerdown', onConfirm, true) + window.removeEventListener('contextmenu', onContextMenu, true) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setLoopCutSegments(null) + setLoopCutEdgeId(null) + setLoopCutSliding(false) + if (outcome !== 'cancel' && latestTopology && latestSelection && latestFactor > 0) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + useCustomMeshEditSession.getState().setSelection(node.id, { + ...latestSelection, + activeId: latestSelection.ids.at(-1) ?? null, + }) + playCustomMeshSfx('operation-commit') + } else if (outcome === 'cancel') { + playCustomMeshSfx('cancel') + } + if (ownsEditSession()) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onConfirm = (pointerEvent: PointerEvent) => { + const action = resolveLoopCutPointerAction('sliding', pointerEvent.button) + if (action !== 'commit-current' && action !== 'commit-centered') return + pointerEvent.preventDefault() + pointerEvent.stopImmediatePropagation() + finish(action) + } + const onContextMenu = (contextEvent: MouseEvent) => { + contextEvent.preventDefault() + contextEvent.stopImmediatePropagation() + } + const onPointerCancel = () => finish('cancel') + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('contextmenu', onContextMenu, true) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + queueMicrotask(() => { + if (finished) return + confirmationAttached = true + window.addEventListener('pointerdown', onConfirm, true) + }) + }, + [loopCutCount, makeRay, node.id, node.topology, ownsEditSession, target], + ) + + const commitCommand = (command: CustomMeshCommand, operator: TopologyOperator) => { + if (cancelDragRef.current) return + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operator)) + const result = applyCustomMeshCommand(node.topology, command) + if (!result.ok) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + setError(result.error) + return + } + useScene.getState().updateNode(node.id, { topology: result.topology }) + useCustomMeshEditSession.getState().setSelection(node.id, { + ...result.selection, + activeId: result.selection.ids.at(-1) ?? null, + }) + setToolbarPanel(null) + setError(null) + if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) + playCustomMeshSfx(operator === 'delete' ? 'delete' : 'operation-commit') + } + + const extrudeSelectedFace = () => { + if (mode !== 'face' || selectedIds.length !== 1) return + commitCommand( + { type: 'extrude-face', faceId: selectedIds[0]!, distance: Number(extrudeDistance) }, + 'extrude', + ) + } + + const insetSelectedFace = () => { + if (mode !== 'face' || selectedIds.length !== 1) return + commitCommand( + { + type: 'inset-face', + faceId: selectedIds[0]!, + amount: Number(insetAmount), + depth: 0, + }, + 'inset', + ) + } + + const deleteSelection = () => { + if (selectedIds.length === 0) return + commitCommand({ type: 'delete-components', selection }, 'delete') + } + + const mergeSelection = () => { + if (mode !== 'vertex' || selectedIds.length < 2) return + commitCommand({ type: 'merge-vertices', vertexIds: selectedIds }, 'merge') + } + + const dissolveSelection = () => { + if (mode !== 'edge' || selectedIds.length !== 1) return + commitCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') + } + + const updateSelection = (next: CustomMeshSelectionState) => { + useCustomMeshEditSession.getState().setSelection(node.id, next) + setError(null) + playCustomMeshSfx('component-select') + } + + const selectAll = () => + updateSelection( + selectAllCustomMeshComponents(displayTopology, { mode, ids: selectedIds, activeId }), + ) + const invertSelection = () => + updateSelection( + invertCustomMeshSelection(displayTopology, { mode, ids: selectedIds, activeId }), + ) + const clearSelection = () => + updateSelection(clearCustomMeshSelection({ mode, ids: selectedIds, activeId })) + + const keyboardActionsRef = useRef({ + canBevel: mode === 'edge', + clearSelection, + deleteSelection, + dissolveSelection, + extrudeSelectedFace, + hasSelection: selectedIds.length > 0, + insetSelectedFace, + invertSelection, + mergeSelection, + selectAll, + }) + keyboardActionsRef.current = { + canBevel: mode === 'edge', + clearSelection, + deleteSelection, + dissolveSelection, + extrudeSelectedFace, + hasSelection: selectedIds.length > 0, + insetSelectedFace, + invertSelection, + mergeSelection, + selectAll, + } + + useEffect(() => { + if (!editing) return + const onKeyDown = (event: KeyboardEvent) => { + const element = event.target as HTMLElement | null + if ( + element?.tagName === 'INPUT' || + element?.tagName === 'TEXTAREA' || + element?.isContentEditable || + cancelDragRef.current + ) + return + const key = event.key.toLowerCase() + const actions = keyboardActionsRef.current + let handled = true + if (key === 'b' && (event.ctrlKey || event.metaKey)) { + if (actions.canBevel) { + playCustomMeshSfx('tool-select') + setBevelSegments(DEFAULT_BEVEL_SEGMENTS) + setTransformTool('bevel') + setToolbarPanel(null) + } + } else if (key === 'a') { + if (event.altKey) actions.clearSelection() + else actions.selectAll() + } else if (key === 'i' && (event.ctrlKey || event.metaKey)) { + actions.invertSelection() + } else if (key === 'g') { + if (actions.hasSelection) { + playCustomMeshSfx('tool-select') + setTransformTool('transform') + } + } else if (key === 'e') { + actions.extrudeSelectedFace() + } else if (key === 'i') { + actions.insetSelectedFace() + } else if (key === 'r') { + if (event.ctrlKey || event.metaKey) { + playCustomMeshSfx('tool-select') + setTransformTool('loop-cut') + setToolbarPanel(null) + } else if (actions.hasSelection) { + playCustomMeshSfx('tool-select') + setTransformTool('transform') + } + } else if (key === 's') { + if (actions.hasSelection) { + playCustomMeshSfx('tool-select') + setTransformTool('transform') + } + } else if (key === 'm') { + actions.mergeSelection() + } else if (key === 'd') { + actions.dissolveSelection() + } else if (event.key === 'Delete' || event.key === 'Backspace' || key === 'x') { + actions.deleteSelection() + } else { + handled = false + } + if (!handled) return + event.preventDefault() + event.stopImmediatePropagation() + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [editing]) + + const moveNode = (event: ReactMouseEvent) => { + event.stopPropagation() + useEditor.getState().setMovingNode(node) + useViewer.getState().setSelection({ selectedIds: [] }) + triggerSFX('sfx:item-pick') + } + const deleteNode = (event: ReactMouseEvent) => { + event.stopPropagation() + useViewer.getState().setSelection({ selectedIds: [] }) + useScene.getState().deleteNode(node.id) + playCustomMeshSfx('delete') + } + + const selectionStatus = formatCustomMeshSelectionStatus(mode, selectedIds.length) + const operationAvailability = customMeshOperationAvailability(mode, selectedIds.length) + const loopCutActive = transformTool === 'loop-cut' + const bevelActive = transformTool === 'bevel' + const componentStatus = customMeshComponentStatus({ + mode, + selectedCount: selectedIds.length, + tool: transformTool, + loopCutCount, + loopCutFactor, + bevelSegments, + }) + + return ( + + {editing ? ( + <> + {mode === 'vertex' + ? displayTopology.vertices.map((vertex) => ( + + )) + : null} + {mode === 'edge' + ? displayTopology.edges.map((edge) => { + const start = vertexById.get(edge.vertexIds[0]) + const end = vertexById.get(edge.vertexIds[1]) + return start && end ? ( + + ) : null + }) + : null} + {mode === 'face' + ? displayTopology.faces.map((face) => { + const center = customMeshFaceCentroid(displayTopology, face) + return ( + + + {xray && center ? ( + + ) : null} + + ) + }) + : null} + {gizmoOrigin && transformTool === 'transform' ? ( + + {(['x', 'y', 'z'] as const).map((axis) => ( + + ))} + {(Object.keys(PLANE_NORMAL) as PlaneAxes[]).map((plane) => ( + + ))} + {(['x', 'y', 'z'] as const).map((axis) => ( + + ))} + + ) : null} + {transformTool === 'loop-cut' && !loopCutSliding + ? displayTopology.edges.map((edge) => { + const start = vertexById.get(edge.vertexIds[0]) + const end = vertexById.get(edge.vertexIds[1]) + return start && end ? ( + + ) : null + }) + : null} + {loopCutSegments ? : null} + + ) : null} + + +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + ref={menuScaleRef} + style={{ transformOrigin: 'center center' }} + > + {editing ? ( +
+ setTransformTool('transform')} + > + + + switchMode('vertex')} + > + + + switchMode('edge')} + > + + + switchMode('face')} + > + + + + {selectionStatus} + + +
+ + {toolbarPanel === 'operations' ? ( + +
+ setExtrudeDistance(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + event.preventDefault() + extrudeSelectedFace() + }} + step="0.05" + type="number" + value={extrudeDistance} + /> + } + disabled={!operationAvailability.extrude} + label="Extrude face" + onClick={extrudeSelectedFace} + shortcut="E" + > + + + setInsetAmount(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + event.preventDefault() + insetSelectedFace() + }} + step="0.05" + type="number" + value={insetAmount} + /> + } + disabled={!operationAvailability.inset} + label="Inset face" + onClick={insetSelectedFace} + shortcut="I" + > + + + + setLoopCutCount( + Math.min(32, Math.max(1, Number(event.target.value) || 1)), + ) + } + step="1" + type="number" + value={loopCutCount} + /> + } + label="Loop Cut and Slide" + onClick={() => { + playCustomMeshSfx('tool-select') + setTransformTool('loop-cut') + setToolbarPanel(null) + }} + shortcut="Ctrl+R" + > + + + + + + + + + { + playCustomMeshSfx('tool-select') + setBevelSegments(DEFAULT_BEVEL_SEGMENTS) + setTransformTool('bevel') + setToolbarPanel(null) + }} + shortcut="Ctrl+B" + > + + +
+
+ ) : null} +
+ + + + + +
+ + setToolbarPanel((current) => (current === 'selection' ? null : 'selection')) + } + > + + + {toolbarPanel === 'selection' ? ( + +
+ + + + + + + + + + setXray((value) => !value)} + > + {xray ? : } + +
+ + + +
+ + ) : null} +
+
+ ) : ( + + )} + {editing && (error || componentStatus) ? ( +
+ {error ?? componentStatus} +
+ ) : null} +
+ + + ) +} + +const CustomMeshSelectionAffordance = () => { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const node = useScene((state) => { + if (selectedIds.length !== 1) return null + const selected = state.nodes[selectedIds[0] as AnyNodeId] + return selected?.type === 'custom-mesh' ? (selected as CustomMeshNode) : null + }) + const [target, setTarget] = useState(null) + const nodeId = node?.id ?? null + const scopeAllowsAffordance = useInteractionScope( + (state) => + state.scope.kind === 'idle' || + (state.scope.kind === 'mesh-editing' && state.scope.nodeId === nodeId), + ) + + useEffect(() => { + if (!nodeId) { + setTarget(null) + return + } + let frameId = 0 + const resolve = () => { + const next = sceneRegistry.nodes.get(nodeId as AnyNodeId) ?? null + setTarget((current) => (current === next ? current : next)) + if (!next) frameId = window.requestAnimationFrame(resolve) + } + resolve() + return () => window.cancelAnimationFrame(frameId) + }, [nodeId]) + + if (!node || !target || !scopeAllowsAffordance) return null + const mount = target.parent ?? target + return createPortal( + , + mount, + undefined, + ) +} + +export default CustomMeshSelectionAffordance diff --git a/packages/nodes/src/custom-mesh/slots.ts b/packages/nodes/src/custom-mesh/slots.ts new file mode 100644 index 0000000000..ff69a19a44 --- /dev/null +++ b/packages/nodes/src/custom-mesh/slots.ts @@ -0,0 +1,20 @@ +import type { CustomMeshNode, SlotDeclaration } from '@pascal-app/core' +import { CUSTOM_MESH_BODY_SLOT_ID, customMeshMaterialSlotIds } from './material-slots' + +export const CUSTOM_MESH_SLOT_ID = CUSTOM_MESH_BODY_SLOT_ID + +function slotLabel(slotId: string): string { + if (slotId === CUSTOM_MESH_SLOT_ID) return 'Body' + return slotId + .split('-') + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join(' ') +} + +export function customMeshSlots(node: CustomMeshNode): SlotDeclaration[] { + return customMeshMaterialSlotIds(node.topology, node.slots).map((slotId) => ({ + slotId, + label: slotLabel(slotId), + })) +} diff --git a/packages/nodes/src/custom-mesh/tool.tsx b/packages/nodes/src/custom-mesh/tool.tsx new file mode 100644 index 0000000000..fc5b3ea788 --- /dev/null +++ b/packages/nodes/src/custom-mesh/tool.tsx @@ -0,0 +1,226 @@ +'use client' + +import { + CustomMeshNode, + collectAlignmentAnchors, + emitter, + type GridEvent, + resolveFrozenFloorPlacementPatch, + resolveSupportSlabPatch, + useSpatialQuery, +} from '@pascal-app/core' +import { + getFloorStackPreviewPosition, + isAlignmentGuideActive, + isGridSnapActive, + isMagneticSnapActive, + movementSfxStepKey, + type PointerSupportSurface, + resolvePointerSupportSurface, + triggerSFX, + useAlignmentGuides, + useEditor, + useInteractionScope, + useRegistryToolContext, +} from '@pascal-app/editor' +import { useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { Group } from 'three' +import { + type FloorPlacementClickTriggerEvent, + getLevelLocalSnappedPosition, + isForcePlacementEvent, + resolveAlignedFloorPlacement, + stopPlacementCommitPropagation, + subscribeFloorPlacementClicks, +} from '../shared/floor-placement' +import { customMeshBounds, customMeshDefinition } from './definition' +import CustomMeshPreview from './preview' + +const CustomMeshTool = () => { + const { activeLevelId, sceneApi, selectNode } = useRegistryToolContext() + const { canPlaceOnFloor } = useSpatialQuery() + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + const cursorRef = useRef(null) + const supportSurfaceRef = useRef(null) + const previousSnapRef = useRef(null) + const cursorVisibleRef = useRef(false) + const [cursorVisible, setCursorVisible] = useState(false) + const [validPlacement, setValidPlacement] = useState(true) + const previewNode = useMemo( + () => + CustomMeshNode.parse({ + ...customMeshDefinition.defaults(), + name: 'Custom Mesh', + position: [0, 0, 0], + }), + [], + ) + + useEffect(() => { + if (!activeLevelId) return + let lastPosition: [number, number, number] | null = null + let alignmentCandidates = collectAlignmentAnchors(sceneApi.nodes(), previewNode.id) + const { size } = customMeshBounds(previewNode) + useInteractionScope.getState().begin({ + kind: 'placing', + node: CustomMeshNode.parse({ + ...previewNode, + parentId: activeLevelId, + metadata: { isNew: true }, + }), + nodeId: previewNode.id, + nodeType: previewNode.type, + view: '3d', + pressDrag: false, + driver: 'registry-tool', + }) + + const pointedSurfaceFor = (event: GridEvent | FloorPlacementClickTriggerEvent) => + typeof HTMLCanvasElement !== 'undefined' && + event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(cameraRef.current, event.position) + : null + + const resolvePlacement = ( + position: [number, number, number], + surface: PointerSupportSurface | null, + ) => { + const draftNode = CustomMeshNode.parse({ + ...customMeshDefinition.defaults(), + name: 'Custom Mesh', + parentId: activeLevelId, + position, + }) + const nodes = { ...sceneApi.nodes(), [draftNode.id]: draftNode } + const patch = surface?.sourceNodeId + ? resolveFrozenFloorPlacementPatch(draftNode, nodes, { + position, + rotation: draftNode.rotation, + elevation: surface.elevation, + preferredSlabId: surface.supportSlabId, + }) + : { + position, + ...resolveSupportSlabPatch(draftNode, nodes, { + maxElevation: surface?.elevation, + pinSupport: true, + }), + } + return { draftNode, patch } + } + + const onGridMove = (event: GridEvent) => { + if (!cursorVisibleRef.current) { + cursorVisibleRef.current = true + setCursorVisible(true) + } + const forcePlacement = isForcePlacementEvent(event) + const pointed = pointedSurfaceFor(event) + supportSurfaceRef.current = pointed + const gridSnapActive = isGridSnapActive() + const { position, guides } = resolveAlignedFloorPlacement({ + node: previewNode, + rawX: pointed?.localPoint?.[0] ?? event.localPosition[0], + rawZ: pointed?.localPoint?.[2] ?? event.localPosition[2], + gridStep: useEditor.getState().gridSnapStep, + candidates: alignmentCandidates, + showAlignment: isAlignmentGuideActive(), + applyAlignmentSnap: isMagneticSnapActive(), + bypassGrid: !gridSnapActive, + }) + useAlignmentGuides.getState().set(guides) + const { patch } = resolvePlacement(position, pointed) + const resolvedPosition = patch.position + const visualPosition = getFloorStackPreviewPosition({ + node: { ...previewNode, ...patch }, + position: resolvedPosition, + rotation: previewNode.rotation, + levelId: activeLevelId, + maxElevation: pointed?.sourceNodeId ? null : pointed?.elevation, + }) + cursorRef.current?.position.set(...visualPosition) + lastPosition = resolvedPosition + const placement = canPlaceOnFloor(activeLevelId, resolvedPosition, size, [ + 0, + previewNode.rotation, + 0, + ]) + setValidPlacement(forcePlacement || placement.valid) + + const snapKey = movementSfxStepKey({ + coords: [resolvedPosition[0], resolvedPosition[2]], + gridSnapActive, + gridStep: useEditor.getState().gridSnapStep, + }) + if (snapKey !== previousSnapRef.current) { + triggerSFX('sfx:grid-snap') + previousSnapRef.current = snapKey + } + } + + const commit = (event: FloorPlacementClickTriggerEvent) => { + const forcePlacement = isForcePlacementEvent(event) + const pointed = pointedSurfaceFor(event) ?? supportSurfaceRef.current + supportSurfaceRef.current = pointed + const fallbackPosition = + lastPosition ?? + getLevelLocalSnappedPosition( + activeLevelId, + event, + useEditor.getState().gridSnapStep, + !isGridSnapActive(), + ) + const position: [number, number, number] = [fallbackPosition[0], 0, fallbackPosition[2]] + const { draftNode, patch } = resolvePlacement(position, pointed) + const placement = canPlaceOnFloor(activeLevelId, patch.position, size, [ + 0, + draftNode.rotation, + 0, + ]) + setValidPlacement(forcePlacement || placement.valid) + if (!(forcePlacement || placement.valid)) { + stopPlacementCommitPropagation(event) + return + } + const node = CustomMeshNode.parse({ + ...draftNode, + ...patch, + }) + sceneApi.upsert(node, activeLevelId) + selectNode(node.id) + triggerSFX('sfx:structure-build') + useAlignmentGuides.getState().clear() + if (useEditor.getState().getContinuation('point') === 'repeat') { + alignmentCandidates = collectAlignmentAnchors(sceneApi.nodes(), previewNode.id) + } else { + cursorVisibleRef.current = false + setCursorVisible(false) + useEditor.getState().setTool(null) + } + stopPlacementCommitPropagation(event) + } + + emitter.on('grid:move', onGridMove) + const unsubscribe = subscribeFloorPlacementClicks(commit) + return () => { + emitter.off('grid:move', onGridMove) + unsubscribe() + useAlignmentGuides.getState().clear() + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'placing' && scope.nodeId === previewNode.id) + } + }, [activeLevelId, canPlaceOnFloor, previewNode, sceneApi, selectNode]) + + if (!activeLevelId) return null + return ( + + + + ) +} + +export default CustomMeshTool diff --git a/packages/nodes/src/custom-mesh/toolbar-state.test.ts b/packages/nodes/src/custom-mesh/toolbar-state.test.ts new file mode 100644 index 0000000000..acf9c85571 --- /dev/null +++ b/packages/nodes/src/custom-mesh/toolbar-state.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import { + customMeshBevelWidthFromDrag, + customMeshComponentStatus, + customMeshOperationAvailability, + customMeshScaleFactorFromDrag, + customMeshScaleFactors, + formatCustomMeshSelectionStatus, +} from './toolbar-state' + +describe('custom mesh toolbar state', () => { + test('enables face operations only for one selected face', () => { + expect(customMeshOperationAvailability('face', 1)).toEqual({ + extrude: true, + inset: true, + merge: false, + dissolve: false, + bevel: false, + }) + expect(customMeshOperationAvailability('face', 2).extrude).toBe(false) + }) + + test('enables component-specific vertex and edge operations', () => { + expect(customMeshOperationAvailability('vertex', 2).merge).toBe(true) + expect(customMeshOperationAvailability('vertex', 1).merge).toBe(false) + expect(customMeshOperationAvailability('edge', 1)).toMatchObject({ + dissolve: true, + bevel: true, + }) + expect(customMeshOperationAvailability('edge', 0).bevel).toBe(true) + }) + + test('formats compact singular and plural selection labels', () => { + expect(formatCustomMeshSelectionStatus('face', 1)).toBe('1 FACE') + expect(formatCustomMeshSelectionStatus('edge', 2)).toBe('2 EDGES') + expect(formatCustomMeshSelectionStatus('vertex', 3)).toBe('3 VERTICES') + }) + + test('does not show a secondary help strip for a selected transform', () => { + expect( + customMeshComponentStatus({ + mode: 'face', + selectedCount: 1, + tool: 'transform', + loopCutCount: 1, + loopCutFactor: 0.5, + bevelSegments: 6, + }), + ).toBeNull() + }) + + test('builds uniform and axis-specific scale factors', () => { + expect(customMeshScaleFactors('uniform', 1.5)).toEqual([1.5, 1.5, 1.5]) + expect(customMeshScaleFactors('x', 1.5)).toEqual([1.5, 1, 1]) + expect(customMeshScaleFactors('y', 0.5)).toEqual([1, 0.5, 1]) + expect(customMeshScaleFactors('z', 2)).toEqual([1, 1, 2]) + }) + + test('converts scale-handle movement into a positive snapped factor', () => { + expect(customMeshScaleFactorFromDrag(0.5, 1)).toBe(1.5) + expect(customMeshScaleFactorFromDrag(0.46, 1, 0.1)).toBe(1.5) + expect(customMeshScaleFactorFromDrag(-5, 1)).toBe(0.01) + }) + + test('maps bevel pointer travel into topology-relative width', () => { + expect(customMeshBevelWidthFromDrag(60, 80, 2, 1000)).toBeCloseTo(0.2) + expect(customMeshBevelWidthFromDrag(0, 0, 2, 1000)).toBe(0) + expect(customMeshBevelWidthFromDrag(10, 0, 2, 0)).toBe(20) + }) +}) diff --git a/packages/nodes/src/custom-mesh/toolbar-state.ts b/packages/nodes/src/custom-mesh/toolbar-state.ts new file mode 100644 index 0000000000..bff42f254e --- /dev/null +++ b/packages/nodes/src/custom-mesh/toolbar-state.ts @@ -0,0 +1,88 @@ +export type CustomMeshToolbarMode = 'vertex' | 'edge' | 'face' +export type CustomMeshScaleAxis = 'uniform' | 'x' | 'y' | 'z' +export type CustomMeshTransformTool = 'transform' | 'loop-cut' | 'bevel' + +export type CustomMeshOperationAvailability = { + extrude: boolean + inset: boolean + merge: boolean + dissolve: boolean + bevel: boolean +} + +export function customMeshOperationAvailability( + mode: CustomMeshToolbarMode, + selectedCount: number, +): CustomMeshOperationAvailability { + return { + extrude: mode === 'face' && selectedCount === 1, + inset: mode === 'face' && selectedCount === 1, + merge: mode === 'vertex' && selectedCount >= 2, + dissolve: mode === 'edge' && selectedCount === 1, + bevel: mode === 'edge', + } +} + +export function formatCustomMeshSelectionStatus( + mode: CustomMeshToolbarMode, + selectedCount: number, +): string { + const label = selectedCount === 1 ? mode : mode === 'vertex' ? 'vertices' : `${mode}s` + return `${selectedCount} ${label}`.toUpperCase() +} + +export function customMeshComponentStatus({ + mode, + selectedCount, + tool, + loopCutCount, + loopCutFactor, + bevelSegments, +}: { + mode: CustomMeshToolbarMode + selectedCount: number + tool: CustomMeshTransformTool + loopCutCount: number + loopCutFactor: number + bevelSegments: number +}): string | null { + if (tool === 'loop-cut') { + return `Loop Cut · ${loopCutCount} cut${loopCutCount === 1 ? '' : 's'} · factor ${loopCutFactor.toFixed(2)} · click or drag an edge · release applies · wheel changes count` + } + if (tool === 'bevel') { + return `Bevel · drag an edge to peel it · wheel changes segments (${bevelSegments}) · release to apply` + } + return selectedCount === 0 ? `Click a ${mode} to select it` : null +} + +export function customMeshScaleFactors( + axis: CustomMeshScaleAxis, + factor: number, +): [number, number, number] { + if (axis === 'uniform') return [factor, factor, factor] + return [axis === 'x' ? factor : 1, axis === 'y' ? factor : 1, axis === 'z' ? factor : 1] +} + +export function customMeshScaleFactorFromDrag( + distance: number, + handleLength: number, + snapStep = 0, +): number { + const safeLength = Math.max(Math.abs(handleLength), 1e-6) + let factor = 1 + distance / safeLength + if (Number.isFinite(snapStep) && snapStep > 0) { + factor = Math.round(factor / snapStep) * snapStep + } + return Math.max(0.01, factor) +} + +export function customMeshBevelWidthFromDrag( + deltaX: number, + deltaY: number, + topologyExtent: number, + viewportHeight: number, +): number { + const safeExtent = Math.max(Math.abs(topologyExtent), 0.001) + const safeViewportHeight = Math.max(Math.abs(viewportHeight), 1) + return (Math.hypot(deltaX, deltaY) * safeExtent) / safeViewportHeight +} diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 35c1bf6b3b..77238ea1d2 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -34,6 +34,7 @@ import { isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, + type PointerSupportSurface, publishPlacementSurface, resolvePointerSupportSurface, type SegmentAngleReference, @@ -477,6 +478,7 @@ const StraightFenceTool: React.FC = () => { const previewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) + const constructionSurface = useRef(null) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) @@ -524,6 +526,7 @@ const StraightFenceTool: React.FC = () => { const stopDrafting = () => { buildingState.current = 0 + constructionSurface.current = null previewRef.current.visible = false setDraftMeasurement(null) setAxisGuide(null) @@ -542,14 +545,20 @@ const StraightFenceTool: React.FC = () => { // (`event.localPosition[1]`) sits at the lift the committed fence // will get. Aiming past the deck edge drops it back to the floor. const pointed = pointedSurfaceFor(cameraRef.current, event) - if (pointed) { + const activeSurface = constructionSurface.current ?? pointed + if (activeSurface) { publishPlacementSurface( - surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + surfacePointScratch.set(event.position[0], activeSurface.worldY, event.position[2]), SURFACE_UP, ) } + const activeY = activeSurface?.localPoint?.[1] ?? event.localPosition[1] const { walls, fences } = getCurrentLevelElements() - const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] + const pointedLocal = buildingState.current === 0 ? pointed?.localPoint : null + const localPoint: FencePlanPoint = [ + pointedLocal?.[0] ?? event.localPosition[0], + pointedLocal?.[2] ?? event.localPosition[2], + ] // While drafting, the segment locks to 15° rays from its start. // Snapping is governed by the snapping mode (`'off'` is the bypass); // there is no Shift hold-to-bypass. Alignment follows the magnetic snap @@ -568,7 +577,7 @@ const StraightFenceTool: React.FC = () => { }), { applySnap: !angleLocked }, ) - endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) + endingPoint.current.set(snappedLocal[0], activeY, snappedLocal[1]) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z]) draftPreview.setFenceDraftEnd(snappedLocal) @@ -619,7 +628,7 @@ const StraightFenceTool: React.FC = () => { magnetic: isMagneticSnapActive(), }), ) - cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) + cursorRef.current.position.set(snappedPoint[0], activeY, snappedPoint[1]) setDraftMeasurement(null) setAxisGuide(null) } @@ -633,6 +642,7 @@ const StraightFenceTool: React.FC = () => { } const { walls, fences } = getCurrentLevelElements() + const pointed = pointedSurfaceFor(cameraRef.current, event) const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] if (buildingState.current === 0) { @@ -644,7 +654,12 @@ const StraightFenceTool: React.FC = () => { magnetic: isMagneticSnapActive(), }), ) - startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) + startingPoint.current.set( + snappedStart[0], + pointed?.localPoint?.[1] ?? event.localPosition[1], + snappedStart[1], + ) + constructionSurface.current = pointed endingPoint.current.copy(startingPoint.current) buildingState.current = 1 const draftPreview = useFloorplanDraftPreview.getState() @@ -675,11 +690,15 @@ const StraightFenceTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return - const pointed = pointedSurfaceFor(cameraRef.current, event) + const pointedSurface = constructionSurface.current ?? pointed const createdFence = createFenceOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, - { supportCap: pointed ? pointed.elevation : null }, + { + supportCap: pointedSurface?.elevation ?? null, + preferredSupportSlabId: pointedSurface?.supportSlabId ?? null, + constructionElevation: pointedSurface?.sourceNodeId ? pointedSurface.elevation : null, + }, ) if (!createdFence) return @@ -700,7 +719,11 @@ const StraightFenceTool: React.FC = () => { // chains its next segment from the same point (its own snap // pipeline can resolve a slightly different endpoint). useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]]) - startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) + startingPoint.current.set( + nextStart[0], + constructionSurface.current?.localPoint?.[1] ?? event.localPosition[1], + nextStart[1], + ) endingPoint.current.copy(startingPoint.current) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setFenceDraftEnd(null) @@ -806,7 +829,7 @@ const SplineFenceDraft: React.FC = () => { cameraRef.current = camera // Pointer cap for the commit (Enter / double-click carry no useful grid // event of their own) — last resolved on move/click. - const supportCapRef = useRef(null) + const supportSurfaceRef = useRef(null) const draftRef = useRef(draftPoints) draftRef.current = draftPoints @@ -831,7 +854,11 @@ const SplineFenceDraft: React.FC = () => { const points = draftRef.current if (points.length >= 2) { const created = createSplineFenceOnCurrentLevel(points, undefined, { - supportCap: supportCapRef.current, + supportCap: supportSurfaceRef.current?.elevation ?? null, + preferredSupportSlabId: supportSurfaceRef.current?.supportSlabId ?? null, + constructionElevation: supportSurfaceRef.current?.sourceNodeId + ? supportSurfaceRef.current.elevation + : null, }) if (created) { triggerSFX('sfx:item-place') @@ -848,27 +875,37 @@ const SplineFenceDraft: React.FC = () => { const trackPointedSurface = (event: GridEvent) => { const pointed = pointedSurfaceFor(cameraRef.current, event) - if (!pointed) return - supportCapRef.current = pointed.elevation + if (!pointed) return null + if (draftRef.current.length === 0) supportSurfaceRef.current = pointed + const activeSurface = supportSurfaceRef.current ?? pointed publishPlacementSurface( - surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + surfacePointScratch.set(event.position[0], activeSurface.worldY, event.position[2]), SURFACE_UP, ) - setLiftY(event.localPosition[1]) + setLiftY(activeSurface.localPoint?.[1] ?? event.localPosition[1]) + return pointed } const onMove = (event: GridEvent) => { - trackPointedSurface(event) - setCursor(snapPoint([event.localPosition[0], event.localPosition[2]])) + const pointed = trackPointedSurface(event) + setCursor( + snapPoint([ + pointed?.localPoint?.[0] ?? event.localPosition[0], + pointed?.localPoint?.[2] ?? event.localPosition[2], + ]), + ) } const onClick = (event: GridEvent) => { - trackPointedSurface(event) + const pointed = trackPointedSurface(event) if (event.nativeEvent.detail >= 2) { commit() return } - const point = snapPoint([event.localPosition[0], event.localPosition[2]]) + const point = snapPoint([ + pointed?.localPoint?.[0] ?? event.localPosition[0], + pointed?.localPoint?.[2] ?? event.localPosition[2], + ]) triggerSFX('sfx:grid-snap') setDraftPoints((prev) => [...prev, point]) } diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index b9040fba74..36ce9e4364 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -7,6 +7,7 @@ import { chimneyDefinition } from './chimney' import { columnDefinition } from './column' import { constructionDimensionDefinition } from './construction-dimension' import { cupolaDefinition } from './cupola' +import { customMeshDefinition } from './custom-mesh/definition' import { doorDefinition } from './door' import { dormerDefinition } from './dormer' import { downspoutDefinition } from './downspout' @@ -67,6 +68,7 @@ export const builtinPlugin: Plugin = { nodes: [ // Stage E-complete (full registry path) shelfDefinition as unknown as AnyNodeDefinition, + customMeshDefinition as unknown as AnyNodeDefinition, spawnDefinition as unknown as AnyNodeDefinition, wallDefinition as unknown as AnyNodeDefinition, fenceDefinition as unknown as AnyNodeDefinition, @@ -136,6 +138,15 @@ export { chimneyDefinition } from './chimney' export { columnDefinition } from './column' export { constructionDimensionDefinition } from './construction-dimension' export { cupolaDefinition } from './cupola' +export { + applyCustomMeshCommand, + type CustomMeshCommand, + type CustomMeshCommandResult, + type CustomMeshSelection, + customMeshFaceCentroid, + customMeshFaceNormal, +} from './custom-mesh/commands' +export { customMeshDefinition } from './custom-mesh/definition' export { doorDefinition } from './door' export { dormerDefinition } from './dormer' export { downspoutDefinition } from './downspout' diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 74a758846a..c9c85a7262 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -206,6 +206,14 @@ export const itemDefinition: NodeDefinition = { capabilities: { selectable: { hitVolume: 'bbox' }, + surfaces: { + top: { + height: (node) => { + const item = node as ItemNodeType + return (item.asset.surface?.height ?? item.asset.dimensions[1]) * item.scale[1] + }, + }, + }, duplicable: true, deletable: true, paint: itemPaint, diff --git a/packages/nodes/src/shared/floor-placement.test.ts b/packages/nodes/src/shared/floor-placement.test.ts index 6cffafbd3b..f575b7fb01 100644 --- a/packages/nodes/src/shared/floor-placement.test.ts +++ b/packages/nodes/src/shared/floor-placement.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from 'bun:test' -import { type GridEvent, type NodeEvent, ShelfNode } from '@pascal-app/core' +import { emitter, type GridEvent, type NodeEvent, ShelfNode } from '@pascal-app/core' import { Object3D } from 'three' -import { getLevelLocalSnappedPosition, resolveAlignedFloorPlacement } from './floor-placement' +import { + getLevelLocalSnappedPosition, + isForcePlacementEvent, + resolveAlignedFloorPlacement, + subscribeFloorPlacementClicks, + subscribeFloorPlacementDoubleClicks, +} from './floor-placement' const nativeEvent = {} as GridEvent['nativeEvent'] @@ -34,4 +40,48 @@ describe('floor placement helpers', () => { expect(getLevelLocalSnappedPosition('missing-level', event, 0.25)).toEqual([0.25, 0, 0.25]) }) + + test('recognizes Alt as force placement', () => { + const event = { + nativeEvent: { altKey: true }, + } as unknown as GridEvent + + expect(isForcePlacementEvent(event)).toBe(true) + expect( + isForcePlacementEvent({ + ...event, + nativeEvent: { altKey: false } as GridEvent['nativeEvent'], + }), + ).toBe(false) + }) + + test('routes generic node clicks and double-clicks without enumerating node kinds', () => { + const node = ShelfNode.parse({ position: [0, 0, 0] }) + const event: NodeEvent = { + node, + position: [0, 0, 0], + localPosition: [0, 0, 0], + object: new Object3D(), + stopPropagation: () => {}, + nativeEvent, + } + let clicks = 0 + let doubleClicks = 0 + const unsubscribeClick = subscribeFloorPlacementClicks(() => { + clicks += 1 + }) + const unsubscribeDoubleClick = subscribeFloorPlacementDoubleClicks(() => { + doubleClicks += 1 + }) + + emitter.emit('node:click', event) + emitter.emit('node:double-click', event) + unsubscribeClick() + unsubscribeDoubleClick() + emitter.emit('node:click', event) + emitter.emit('node:double-click', event) + + expect(clicks).toBe(1) + expect(doubleClicks).toBe(1) + }) }) diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts index e5b3841dcc..2f76566054 100644 --- a/packages/nodes/src/shared/floor-placement.ts +++ b/packages/nodes/src/shared/floor-placement.ts @@ -1,6 +1,5 @@ import { type AnyNode, - type EventSuffix, emitter, type GridEvent, movingFootprintAnchors, @@ -13,22 +12,12 @@ import { Vector3 } from 'three' export const FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M = 0.08 -export const FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS = [ - 'shelf', - 'item', - 'slab', - 'ceiling', - 'wall', - 'fence', - 'column', - 'roof', - 'roof-segment', - 'stair', - 'stair-segment', -] as const - export type FloorPlacementClickTriggerEvent = GridEvent | NodeEvent +export function isForcePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { + return event.nativeEvent?.altKey === true +} + type FloorPlacementAlignmentArgs = { node: AnyNode rawX: number @@ -132,19 +121,11 @@ export function subscribeFloorPlacementClicks( onClick: (event: FloorPlacementClickTriggerEvent) => void, ) { emitter.on('grid:click', onClick) - type SuffixedKey = `${K}:${EventSuffix}` - type ClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]> - for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.on(key, onClick as never) - } + emitter.on('node:click', onClick) return () => { emitter.off('grid:click', onClick) - for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.off(key, onClick as never) - } + emitter.off('node:click', onClick) } } @@ -152,18 +133,10 @@ export function subscribeFloorPlacementDoubleClicks( onDoubleClick: (event: FloorPlacementClickTriggerEvent) => void, ) { emitter.on('grid:double-click', onDoubleClick) - type SuffixedKey = `${K}:${EventSuffix}` - type DoubleClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]> - for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { - const key = `${kind}:double-click` as DoubleClickKey - emitter.on(key, onDoubleClick as never) - } + emitter.on('node:double-click', onDoubleClick) return () => { emitter.off('grid:double-click', onDoubleClick) - for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { - const key = `${kind}:double-click` as DoubleClickKey - emitter.off(key, onDoubleClick as never) - } + emitter.off('node:double-click', onDoubleClick) } } diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index 35c7ce9561..22cbe9a3cd 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -67,6 +67,36 @@ function findMatchingSceneMaterial( return null } +export type SlotPaintMaterialResolution = { + ref: string | undefined + newSceneMaterial: SceneMaterial | null +} + +export function resolveSlotPaintMaterialRef( + materials: Record, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): SlotPaintMaterialResolution | null { + if (material === undefined && materialPreset === undefined) { + return { ref: undefined, newSceneMaterial: null } + } + if (materialPreset) return { ref: materialPreset, newSceneMaterial: null } + if (!material) return null + + const existing = findMatchingSceneMaterial(materials, material) + if (existing) return { ref: toSceneMaterialRef(existing.id), newSceneMaterial: null } + + const id = generateSceneMaterialId() + return { + ref: toSceneMaterialRef(id), + newSceneMaterial: { + id, + name: `Material ${Object.keys(materials).length + 1}`, + material, + }, + } +} + function commitSlotPaint( node: SlotsNode, role: string, @@ -76,30 +106,9 @@ function commitSlotPaint( const nodeId = node.id as AnyNodeId const state = useScene.getState() const currentNode = (state.nodes[nodeId] as SlotsNode | undefined) ?? node - - let ref: string | undefined - let newSceneMaterial: SceneMaterial | null = null - - if (material === undefined && materialPreset === undefined) { - ref = undefined - } else if (materialPreset) { - ref = materialPreset - } else if (material) { - const existing = findMatchingSceneMaterial(state.materials, material) - if (existing) { - ref = toSceneMaterialRef(existing.id) - } else { - const id = generateSceneMaterialId() - newSceneMaterial = { - id, - name: `Material ${Object.keys(state.materials).length + 1}`, - material, - } - ref = toSceneMaterialRef(id) - } - } else { - return - } + const resolution = resolveSlotPaintMaterialRef(state.materials, material, materialPreset) + if (!resolution) return + const { ref, newSceneMaterial } = resolution const nextSlots = { ...(currentNode.slots ?? {}) } if (ref) nextSlots[role] = ref diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index 5eac7859e3..ab9f3587c4 100644 --- a/packages/nodes/src/wall/definition.test.ts +++ b/packages/nodes/src/wall/definition.test.ts @@ -32,3 +32,28 @@ describe('wallDefinition floor-plan extension', () => { expect(canCurve?.({ node: { ...wall, children: [] }, nodes })).toBe(true) }) }) + +test('wall top surface follows the effective level-bound height', () => { + const level = { + object: 'node', + id: 'level_test', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + height: 3.2, + } as AnyNode + const wall = wallDefinition.schema.parse({ + id: 'wall_test', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const nodes = { [level.id]: level, [wall.id]: wall } + const height = wallDefinition.capabilities.surfaces?.top?.height + + expect(typeof height).toBe('function') + expect(typeof height === 'function' ? height(wall, { nodes }) : height).toBe(3.2) +}) diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 0eb259d4ad..71d4b265d3 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -1,4 +1,10 @@ -import type { AnyNodeId, NodeDefinition } from '@pascal-app/core' +import { + type AnyNodeId, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, + type NodeDefinition, + type WallNode as WallNodeType, +} from '@pascal-app/core' import type { FloorplanNodeExtension } from '@pascal-app/editor' import { buildWallContextualDimensions } from './contextual-dimensions' import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan' @@ -73,6 +79,14 @@ export const wallDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, // Front + back faces host items (paintings, shelves, switches). surfaces: { + top: { + height: (node, { nodes }) => { + const wall = node as WallNodeType + return ( + getWallBaseElevationForNodes(wall, nodes) + getWallEffectiveHeightForNodes(wall, nodes) + ) + }, + }, sides: { faces: 'all' }, }, duplicable: true, diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 184fdec2d6..745c2a172e 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -481,6 +481,7 @@ export const WallTool: React.FC = () => { // snapping onto the chain's own segments never reads as a join. const chainWallIds = useRef([]) const constructionPlane = useRef(null) + const flatConstructionBase = useRef(false) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) @@ -605,6 +606,7 @@ export const WallTool: React.FC = () => { const stopDrafting = () => { buildingState.current = 0 constructionPlane.current = null + flatConstructionBase.current = false chainFirstVertex.current = null chainWallIds.current = [] const draftPreview = useFloorplanDraftPreview.getState() @@ -755,6 +757,7 @@ export const WallTool: React.FC = () => { : null) ?? resolveEventConstructionPlane(event, pointed) const plane = resampleTerrainConstructionPlane(resolvedPlane, snappedStart) constructionPlane.current = plane + flatConstructionBase.current = pointed?.sourceNodeId != null publishHorizontalConstructionPlane(event, plane) gridPosition = snappedStart startingPoint.current.set(snappedStart[0], plane.localY, snappedStart[1]) @@ -806,6 +809,8 @@ export const WallTool: React.FC = () => { preferredSupportSlabId: draftPlane?.supportSlabId ?? null, constructionElevation: draftPlane?.elevation ?? null, constructionHeight: previewHeightRef.current, + constructionSourceNodeId: constructionPlane.current?.sourceNodeId ?? null, + flatConstructionBase: flatConstructionBase.current, }, ) if (!createdWall) return diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 98fd5d043b..b583f2a83e 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -34,6 +34,7 @@ export function useNodeEvents(node: NodeByKind, type: // keys; the `as never` cast lets us emit a kind-specific payload // through that generic surface without enumerating every kind. emitter.emit(eventKey, payload as never) + emitter.emit(`node:${suffix}`, payload) } // Camera drags (orbit / pan / dolly) suppress ALL node pointer events. diff --git a/wiki/architecture/events.md b/wiki/architecture/events.md index bba5fa2381..75a3c5162f 100644 --- a/wiki/architecture/events.md +++ b/wiki/architecture/events.md @@ -12,12 +12,16 @@ The event bus (`emitter`) is a global `mitt` instance typed with `EditorEvents`. ``` : +node: ``` -Example keys: `wall:click`, `item:enter`, `door:double-click`, `grid:pointerdown` +Example keys: `wall:click`, `custom-mesh:enter`, `node:click`, `grid:pointerdown` ### Node Types -`wall` `item` `site` `building` `level` `zone` `slab` `ceiling` `roof` `window` `door` +Every registered `AnyNode` discriminator is available as a typed node-event +prefix, including `custom-mesh`. `node:*` is the cross-kind channel for +consumers that intentionally handle every node kind without maintaining a +parallel list. ### Suffixes ```ts @@ -39,7 +43,8 @@ interface NodeEvent { } ``` -Grid events only carry `position` and `nativeEvent` (no `node`). +Grid events carry `position`, `localPosition`, optional hit metadata, and +`nativeEvent` (but no `node`). ## Selection Intent Events @@ -62,7 +67,9 @@ const events = useNodeEvents(node, 'wall') return ``` -`useNodeEvents` converts R3F `ThreeEvent` into a `NodeEvent` and emits `wall:click`, `wall:enter`, etc. It suppresses events while the camera is dragging. +`useNodeEvents` converts R3F `ThreeEvent` into a `NodeEvent` and emits both the +kind-specific event (`wall:click`, `custom-mesh:enter`, etc.) and its generic +`node:*` counterpart. It suppresses events while the camera is dragging. ## Listening diff --git a/wiki/architecture/interaction-scope.md b/wiki/architecture/interaction-scope.md index 09b66aa97c..35a2c8f5f3 100644 --- a/wiki/architecture/interaction-scope.md +++ b/wiki/architecture/interaction-scope.md @@ -1,6 +1,6 @@ # Interaction Scope -*The authoritative interaction state machine ("the spine") — one scope describes "what the user is currently doing".* +_The authoritative interaction state machine ("the spine") — one scope describes "what the user is currently doing"._ Applies to: `packages/editor/src/lib/interaction/**`, `packages/editor/src/store/use-interaction-scope.ts`. @@ -19,16 +19,17 @@ scope is exactly one interaction at a time, and `idle` carries no payload. `InteractionScope` (`lib/interaction/scope.ts`) is a discriminated union on `kind`: -| `kind` | Payload | What | -|---|---|---| -| `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. | -| `placing` | `node`, `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `node` carries the not-yet-committed draft; `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. | -| `moving` | `node`, `nodeId`, `nodeType`, `view` | Moving an existing node. | -| `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. | -| `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). | -| `reshaping` | `nodeId`, `reshape`, `driver`, `holeIndex?`, `endpoint?`, `index?`, `side?` | Reshaping a selected node's geometry. `driver` identifies the interaction body that owns preview and commit. | -| `box-select` | — | Marquee selection drag. | -| `painting` | — | Material paint application. | +| `kind` | Payload | What | +| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. | +| `placing` | `node`, `nodeId`, `nodeType`, `view`, `pressDrag`, `driver` | Placing a fresh node (catalog/preset/build tool). `node` carries the not-yet-committed draft; `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. `driver` identifies the sole interaction body that owns preview and commit. | +| `moving` | `node`, `nodeId`, `nodeType`, `view` | Moving an existing node. | +| `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. | +| `mesh-editing` | `nodeId`, `phase`, `operator?` | Editing one node's internal mesh components. Held for the complete edit-mode session; `phase` distinguishes component selection from an in-flight operator. | +| `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). | +| `reshaping` | `nodeId`, `reshape`, `driver`, `holeIndex?`, `endpoint?`, `index?`, `side?` | Reshaping a selected node's geometry. `driver` identifies the interaction body that owns preview and commit. | +| `box-select` | — | Marquee selection drag. | +| `painting` | — | Material paint application. | `reshaping` groups endpoint/curve/hole/boundary/control-point/tangent edits as sub-states of one scope — there is one node and one in-flight reshape, so @@ -41,7 +42,7 @@ mounting for the same gesture. Placing and moving use `view: '2d' | '3d'`. ### Helpers - `isIdle(scope)` / `isActive(scope)` — `idle` vs anything else (`ActiveInteractionScope`). -- `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node. +- `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node. A `mesh-editing` scope targets the custom mesh whose topology owns the session. - `isToolDrivenReshape(scope)` / `isFloorplanDrivenReshape(scope)` — narrow reshape ownership so only the matching interaction body mounts. - `selectionEnabled(scope)` — true only while `idle`. During any active interaction the pointer belongs to that interaction's body, not to selecting a different object; the picking choke point must not route a hover/click to selection while this is false. @@ -54,12 +55,14 @@ mounting for the same gesture. Placing and moving use `view: '2d' | '3d'`. single owner. Exactly one scope at a time; the only writable shape is `InteractionScope`, so there is no setter that can leave a half-state. -| Method | Behaviour | -|---|---| -| `begin(scope: ActiveInteractionScope)` | Enter an interaction. If one is already active it is replaced (single owner, no producer races). | -| `update(patch)` | Patch the current scope's payload. **Ignored when idle, and ignored when the patch's `kind` differs from the active kind** — payload updates must not change which interaction is running (use `begin` for that). | -| `end()` | Return to idle atomically. Both commit and cancel call it; the write-vs-revert distinction lives in the interaction body, not here. | -| `endIf(match)` | Return to idle only if the active scope satisfies `match`. | +| Method | Behaviour | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `begin(scope: ActiveInteractionScope)` | Enter an interaction. If one is already active it is replaced (single owner, no producer races). | +| `update(patch)` | Patch the current scope's payload. **Ignored when idle, and ignored when the patch's `kind` differs from the active kind** — payload updates must not change which interaction is running (use `begin` for that). | +| `end()` | Return to idle atomically. Both commit and cancel call it; the write-vs-revert distinction lives in the interaction body, not here. | +| `endIf(match)` | Return to idle only if the active scope satisfies `match`. | + +The `mesh-editing` scope is the global ownership summary, not a container for kind-specific component state. Custom mesh keeps its vertex/edge/face mode, selected IDs, active component, and active material slot in a kind-owned transient store under `packages/nodes/src/custom-mesh/`. The canvas affordance and custom inspector share that store while the scope owns the session. Entering another mesh transfers ownership; scope loss, explicit exit, and unmount clear only the matching node's session. Persisted topology and material slots remain in `useScene`. **Atomic-end invariant.** `end()` sets the scope back to `IDLE_SCOPE` in one write — no interaction payload can leak past the end of its interaction (no stale @@ -80,7 +83,7 @@ the node's `asset.attachTo` plus whether a candidate exposes a top surface. - `wall` — `attachTo` of `wall` or `wall-side`. - `ceiling` — `attachTo` of `ceiling`. -- `surface` — everything else ("floor item" really means *surface-resting*: rests on the floor **or** any host's top surface). +- `surface` — everything else ("floor item" really means _surface-resting_: rests on the floor **or** any host's top surface). `isPickableForAttach(placed, candidate)` decides, for a node of attach class `placed`, whether a `HotSetCandidate` is a valid host/surface: @@ -91,7 +94,7 @@ the node's `asset.attachTo` plus whether a candidate exposes a top surface. `isCandidateInHotSet(scope, placedAttachClass, candidate)` lifts this to a whole scope: -- `idle` → `true` (selection/phase filtering stays in the selection manager; the hot-set only narrows what an *active* interaction can target). +- `idle` → `true` (selection/phase filtering stays in the selection manager; the hot-set only narrows what an _active_ interaction can target). - `placing` / `moving` → `isPickableForAttach`, or `true` when `placedAttachClass` is `null`. - every other active scope → `false`: nothing in the scene is a placement target, so the interaction body's own raycast owns the pointer. @@ -108,14 +111,14 @@ module pure and unit-testable without the scene or registry. any non-idle scope, scene objects stay visible but non-pickable, and DOM/HUD overlays step back differentiated by how distracting they are. -| Overlay | Idle | Any active scope | -|---|---|---| -| Zone labels | shown | hidden (not a primary editing concern) | -| Context badges (hover name pills) | shown | faded + `pointer-events: none` | -| Conflicting controls (other objects' handles, floating action menu) | shown | hidden | -| Scene objects pickable | yes | no (the hot-set owns targeting; context preserved, can't grab the wrong thing) | -| Active affordances (ghost, snap guides, dimension labels, the active handle) | shown | shown | -| Contextual control HUD interactive | yes | yes (it *is* the active interaction's own controls — exempt from the pointer-events step-back) | +| Overlay | Idle | Any active scope | +| ---------------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------- | +| Zone labels | shown | hidden (not a primary editing concern) | +| Context badges (hover name pills) | shown | faded + `pointer-events: none` | +| Conflicting controls (other objects' handles, floating action menu) | shown | hidden | +| Scene objects pickable | yes | no (the hot-set owns targeting; context preserved, can't grab the wrong thing) | +| Active affordances (ghost, snap guides, dimension labels, the active handle) | shown | shown | +| Contextual control HUD interactive | yes | yes (it _is_ the active interaction's own controls — exempt from the pointer-events step-back) | The policy is binary (`IDLE_POLICY` vs `ACTIVE_POLICY`) keyed on `isActive`. @@ -124,7 +127,7 @@ The policy is binary (`IDLE_POLICY` vs `ACTIVE_POLICY`) keyed on `isActive`. ## Snapping mode & modifiers (the unified model) Snapping is a persistent, **per-context**, always-visible mode — not a held-Shift bypass. -The active scope selects the *context*; the context's current mode selects the *behaviour*. +The active scope selects the _context_; the context's current mode selects the _behaviour_. There is no per-kind snapping switch. - **Contexts** (`lib/snapping-mode.ts`, `SNAP_PROFILES`): `wall` (grid/lines/angles/off, default grid), @@ -147,6 +150,7 @@ There is no per-kind snapping switch. **Known-legacy (migrate on touch).** Two legacy modifier patterns predate this model and survive in spots not yet touched; both are tracked in `plans/editor-placement-interaction-overhaul.md`. A PR that **touches** one must migrate it to the model above, not extend the legacy path: + 1. **`event.shiftKey` as a snap bypass with hardcoded steps** — the MEP move/endpoint tools (`packages/nodes/src/{duct-segment,pipe-segment,liquid-line,lineset,duct-fitting}/{move-tool,selection}.tsx`). Opening a `moving` scope from a bespoke mover is **not** the migration — `useMovingNode()` reads the scope, diff --git a/wiki/architecture/materials-and-themes.md b/wiki/architecture/materials-and-themes.md index 78d32ea155..cbde8eee2d 100644 --- a/wiki/architecture/materials-and-themes.md +++ b/wiki/architecture/materials-and-themes.md @@ -62,6 +62,26 @@ So picking the Mediterranean theme gives a blue roof + warm walls without touchi Each of these reads `shading`/`textures`/`colorPreset`/`sceneTheme` from `useViewer` (or receives them threaded from `GeometrySystem`) and **must include `sceneTheme` in its material cache key and its rebuild dependency array**, or theme switches won't re-colour. `GeometrySystem` marks every geometry node dirty on any of those changing. +## Custom-mesh face materials + +Custom meshes use the reusable `MaterialRef` model at face granularity. `CustomMeshNode.slots` maps stable object-local slot IDs to `scene:` or `library:` references, while each `CustomMeshFace.materialSlot` stores one slot ID. `body` is the permanent base slot and the fallback for unbound or unresolved face slots. + +The geometry builder emits one Three.js group per topology face and a material array ordered by the node's stable slot IDs. It publishes that render-material order as `userData.slotIds` and records each face's vertex range in `geometry.userData.customMeshFaces`. The paint capability re-raycasts the mesh and maps the hit triangle through those ranges to a stable topology face ID, so preview and commit affect only that face. Face UVs retain the world-scale projection contract below. + +The custom-mesh inspector lists only slots already present on that mesh; it does not embed the material library. A compact dropdown below the list exposes deduplicated `MaterialRef`s already used by scene node slots, plus custom scene-material datablocks that are not yet in use. With faces selected in Edit Mode, choosing a reusable material immediately reuses an object slot with the same `MaterialRef`, or creates one when that material is new to the mesh, and assigns it to those faces in one update. Choosing an object slot changes only the transient assignment source; **Assign** applies that slot to the selected faces. **Select** and **Deselect** only add or subtract faces using the active object slot from transient component selection. + +Deleting a non-body item from the Face Materials list removes only that mesh-local slot. Every face assigned to it is remapped to the first slot (`body`) in the same node update, and `body` becomes the active assignment source. The reusable scene or library material remains available to other nodes. + +The global Paint tool is the creation path for new face materials. A paint hit resolves one topology face, reuses an existing object slot by exact `MaterialRef` identity or creates one stable slot, and assigns only that face. A one-off material reuses a structurally matching scene material before creating a new reusable scene material. Erasing assigns `body`; there is no unassigned face state. + +Topology operators preserve assignments deterministically: + +- retained and transformed faces keep their slot; +- extrude caps/sides and inset caps/rings inherit the source face; +- loop-cut pieces inherit the face they split; +- bevel bands and mixed-material dissolve use the first adjacent face in stable `topology.faces` order; +- deleting the last face that uses a slot does not delete its reusable material. + ### External plugin renderers Plugin renderers follow the same four axes through the public `@pascal-app/viewer` diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 1f96451897..6a0a1cb3eb 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -26,7 +26,7 @@ The invariant, in one sentence: | `slab.recessed` | Recess intent: open shell whose floor is `elevation` and whose rim is `recessedRimElevation`. Excluded from "covering" queries and wall-face adoption. | Solid slab. | | `slab.recessedRimElevation` | Optional rim anchor for a raised/lowered recess. Relative presets preserve this anchor while changing depth. | Level plane (`0`), preserving legacy pools. | | `slab.fillToTerrain` | Adds a terrain-following perimeter foundation below a solid slab's fixed underside. The walking surface and authored structural thickness stay flat. | No terrain foundation. | -| `supportSlabId` | Persisted support host on walls and all floor-placed kinds. Written at commit **only when overlapping supports disagree on elevation**; `'ground'` sentinel pins bare ground under a deck. | Support is elected per query (coverage election for walls, footprint max for items). | +| `supportSlabId` | Persisted support host on walls and all floor-placed kinds. Written at commit **only when overlapping supports disagree on elevation**; `'ground'` sentinel pins bare ground under a deck. Structural custom meshes always pin their placement-time host so a room slab generated above them cannot feed back and lift the platform. | Support is elected per query (coverage election for walls, footprint max for items). | | `wall.supportOffset` | Optional level-local delta from the elected support. Terrain wall chains use it to keep every segment on the first point's construction plane while storing only one number, never terrain samples. | Zero offset: the wall sits directly on its elected slab or sculpted ground source. | | `fence.supportOffset` | Optional level-local delta from the fence's slab host or level plane. It translates the complete fence while preserving height. | Zero offset: the fence sits directly on its host or level plane. | | `wall.fillToTerrain` | Extends the wall downward from its authored base to the terrain with independently sampled left/right faces. The wall body height and top stay unchanged. | Fixed base with no terrain infill. | @@ -66,7 +66,7 @@ Two schema rules protect these semantics: ## Pointer-decided placement -Grid events intersect a plane that rides the ghost's elevation, so any stacked-surface decision must come from the true camera ray, not the plane hit: `getPointedSupportSurface` returns the nearest eligible surface plus the crossing point, and both the support-election cap (`maxElevation`) and the cursor XZ derive from that single computation. Pointing under a deck elects the floor; pointing at the deck top elects the deck. Wall drafting may additionally include upward-facing wall, stackable-item, and column meshes. Those node-top hits freeze a scalar construction plane for the throw; they are not a persistent hosting edge and do not follow later host edits. Commits persist the elected slab/ground source plus `wall.supportOffset`. 2D floorplan placement has no camera ray and keeps max-election. +Grid events intersect a plane that rides the ghost's elevation, so any stacked-surface decision must come from the true camera ray, not the plane hit: `getPointedSupportSurface` returns the nearest eligible surface plus the crossing point, and both the support-election cap (`maxElevation`) and the cursor XZ derive from that single computation. Pointing under a deck elects the floor; pointing at the deck top elects the deck. Upward-facing custom-mesh geometry is a shared placement surface for slabs, fences, columns, stairs, items, and registry-driven floor objects; wall drafting may additionally include upward-facing wall, stackable-item, and column geometry. Those node-top hits freeze a scalar construction plane for the throw; they are not a persistent hosting edge and do not follow later host edits. Slabs store the plane as `elevation`, walls and fences as `supportOffset`, and floor-placed position nodes as their canonical Y offset. Each also pins the slab or ground beneath the custom mesh, preventing a later generated slab from feeding back and lifting the placed object. Ordinary slab/ground hits persist their elected support source and retain the normal stepped-base behavior. 2D floorplan placement has no camera ray and keeps max-election. Wall and slab drafting share the horizontal construction-plane resolver. A slab freezes the first snapped vertex's plane, keeps later vertices on that flat plane, and translates its authored diff --git a/wiki/blender-edit-mode-research.md b/wiki/blender-edit-mode-research.md new file mode 100644 index 0000000000..2fbe6a23c5 --- /dev/null +++ b/wiki/blender-edit-mode-research.md @@ -0,0 +1,406 @@ +# Blender-Style Custom Mesh Edit Mode Research + +## Purpose and conclusion + +This brief describes the Edit Mode interaction shown by the linked X post, how Blender's mesh Edit Mode actually behaves, what the current Pascal custom-mesh slice already implements, and the staged work required for a credible Blender-like experience. External behavior claims use only current first-party Blender manuals, developer documentation, and API documentation. Repository claims come from the current worktree, audited on 2026-08-10. + +The central recommendation is unchanged but now concrete: + +1. Keep persistent, adjacency-rich topology with stable component IDs as the source of truth. `THREE.BufferGeometry` remains a derived render and picking artifact. +2. Treat Edit Mode as a persistent editor session containing component selection and display state. +3. Run every transform or topology command through one modal preview → confirm/cancel lifecycle, regardless of whether it starts from a gizmo, keyboard shortcut, or toolbar button. +4. Deliver in dependency order. The current box, component selection, axis translation, and single-face extrusion are a useful vertical slice, not Blender parity. + +## What the linked video suggests + +The [37-second X video](https://x.com/00namazu86_7/status/2079180451521200550) primarily demonstrates architectural massing: a rectangular footprint becomes a shallow solid, a top face is raised with a measured handle, and contextual surface actions lead into higher-level building and material workflows. The closest first product target is therefore **face Push/Pull on an editable architectural solid**, built on a topology model capable of growing toward Blender-style component editing. + +That does not mean the custom mesh should absorb Pascal's semantic model. Walls, slabs, roofs, openings, balconies, and hosted items should remain semantic nodes or explicit semantic commands. Materials and day/night presentation remain orthogonal to topology. Stable hosting on a custom-mesh face would require a later face-host contract that survives topology remapping. + +## Current Pascal implementation + +The worktree already contains a coherent first vertical slice. + +| Area | Current implementation | Current boundary | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Persistent schema | [`CustomMeshNode`](../packages/core/src/schema/nodes/custom-mesh.ts) stores level-local position/rotation plus stable-ID vertices, undirected edges, ordered face vertex loops, per-face `materialSlot`, and optional slots. A box is the default topology. | No face-corner/loop attributes, holes, explicit adjacency, or topology version/revision. | +| Validation | `inspectCustomMeshTopology` rejects duplicate component IDs, self-edges, missing vertex references, duplicate undirected edges, faces with fewer than three distinct vertices, and missing face-boundary edges. | It does not yet define a manifold policy or reject zero-length edges, repeated vertices in a longer face loop, duplicate faces, non-planar/zero-area/self-intersecting faces, inconsistent winding, or failed triangulation. | +| Pure commands | [`commands.ts`](../packages/nodes/src/custom-mesh/commands.ts) exposes component translate/rotate/scale/delete plus single-face extrude/inset, returns a new topology plus selection, preserves input immutability, allocates stable IDs, and validates the result. | Selection supports one mode at a time, has no identity remap, and extrude/inset handle exactly one face with immediate numeric parameters rather than modal region operations. | +| Derived geometry | [`geometry.ts`](../packages/nodes/src/custom-mesh/geometry.ts) projects each face to 2D, triangulates it, generates flat normals/UVs/material groups, and stores triangle ranges keyed by face ID. | Triangulation assumes a usable planar simple loop; rebuilding replaces the whole `BufferGeometry`; the face-range metadata is not yet the main component picker. | +| Registry integration | [`definition.ts`](../packages/nodes/src/custom-mesh/definition.ts) registers geometry, floor-plan output, placement preview/tool, selection affordance, item-style snapping, move/delete/duplicate, floor placement, collision, and materials. The node is registered in the built-in plugin and appears through registry-driven build UI. | Object rotation is stored but no rotatable capability is declared. The floor plan uses the XZ convex hull, so concavity and overhang distinctions are lost. | +| Edit UI | [`selection.tsx`](../packages/nodes/src/custom-mesh/selection.tsx) mounts only for a sole selected custom mesh. It has a dedicated `mesh-editing` interaction scope, active-white/selected-orange components, topology-aware `1`/`2`/`3` conversion, All/Invert/Clear, X-Ray, tool-gated compact move handles, numeric rotate/scale, Extrude, Inset, Merge at Center, Dissolve Edge, component Delete, shortcuts, and Done/Tab. | Detailed session state is still component-local; there is no mixed-mode or box selection, face-center picking, plane/free transform handles, orientation/pivot controls, proportional editing, or full modal operator engine. | +| Preview/history | The complete Edit Mode session owns the `mesh-editing` scope, suppressing object selection and whole-node movement. Axis drag snapshots topology, previews through `useLiveNodeOverrides`, clears on cancel/unmount, and performs one `updateNode` on release. Numeric operators perform one validated scene update. | Numeric operators do not yet provide pointer preview/cancel, typed modal input, or a durable last-operator record for parameter replay. | +| Tests | Core schema tests cover the default box and a missing boundary edge. Command tests cover extrusion, ID allocation, translation, rotation, scale, inset, deletion, and topology validity. Selection tests cover active identity, toggling, conversion, All, and Invert; scope tests cover persistent mesh-edit ownership. | No tests yet cover pointer picking/occlusion, component visuals, preview cancellation/history, save/reload, floor-plan updates, degeneracies, performance, or end-to-end user workflows. | + +This implementation already follows two important repository precedents: + +- Polygon editing already provides direct vertex/edge affordances through the shared [`PolygonEditor`](../packages/editor/src/components/tools/shared/polygon-editor.tsx). +- The slab boundary editor already demonstrates the desired one-undo transaction: preview through `useLiveNodeOverrides`, mark dirty at pointer rate, clear on cancel/unmount, and perform one scene update on release. [Slab boundary editor](../packages/nodes/src/slab/boundary-editor.tsx), [Pascal tool rules](architecture/tools.md) + +## Blender's actual Edit Mode UX + +### Entry, exit, and mode ownership + +Blender uses `Tab` to toggle Edit Mode for supported objects. Entering a mode changes viewport appearance, header, toolbar, menus, and the shortcut map; Object Mode transforms the object while Edit Mode changes its components. Blender also supports multiple objects in Edit Mode, but cannot connect geometry across different objects. [Blender object modes](https://docs.blender.org/manual/en/latest/editors/3dview/modes.html) + +For Pascal v1, the transferable behavior is explicit mode ownership, not multi-object editing. Tab and an Edit Mesh action should enter a sole selected custom mesh; Tab should exit when no modal command is active. Escape should first cancel the current command. The canvas, contextual HUD, shortcut routing, and component overlays must all derive from the same session. + +### Component modes, visuals, and active element + +Blender's `1`, `2`, and `3` modes have a precise visual vocabulary: + +- Vertex mode shows vertices as points: unselected black, selected orange, active/last-selected white. +- Edge mode hides vertex points: unselected edges black, selected edges yellow/orange, active edge white. +- Face mode shades selected faces orange and gives the active face a white border. + +Shift allows multiple component modes. Ascending mode conversion keeps only complete higher-order elements; descending conversion selects every constituent. Ctrl changes switching into expand/contract behavior. [Blender mesh selection](https://docs.blender.org/manual/en/latest/modeling/meshes/selecting/introduction.html) + +Blender's overlays can additionally show face orientation, selected-face fill, face centers, indices, edge lengths/angles, face areas/angles, and normals; measurements update while components transform. [Blender viewport overlays](https://docs.blender.org/manual/en/latest/editors/3dview/display/overlays.html) + +Pascal now tracks an active ID and ordered selected IDs, and mode switching normalizes one coherent topology selection instead of clearing it. That ordering contract must be preserved when Merge at First/Last and Active Element pivot are added. + +### Occlusion and X-Ray + +With X-Ray off, occluded geometry is not selectable. X-Ray enables through-selection; in Face mode, selection uses face-center dots rather than clicking anywhere on the filled surface. Blender notes that dense overlapping components can still cause region-selection misses and that a concave n-gon's center dot can fall somewhere visually misleading. [Blender mesh selection](https://docs.blender.org/manual/en/latest/modeling/meshes/selecting/introduction.html) + +Pascal now exposes a visual X-Ray toggle and depth-tests component overlays by default. Full picking parity still requires: + +- Default: depth-tested, frontmost component picking and visuals. +- X-Ray toggle: through-picking plus visually muted occluded components. +- Face mode in X-Ray: explicit face-center pick targets. +- Region selection: screen-space point/edge/face tests with a documented frontmost-versus-through policy. + +### Transform gizmos versus modal `G`/`R`/`S` + +Blender supports two input surfaces over one transform model. Object gizmos also apply to mesh components: red/green/blue axes constrain to one axis; Move and Scale include two-axis plane squares; white handles provide view-plane/free movement, view rotation/trackball behavior, or uniform scale. Gizmos can be shown or hidden independently. [Blender viewport gizmos](https://docs.blender.org/manual/en/latest/editors/3dview/display/gizmo.html) + +`G`, `R`, and `S` start keyboard modal Move, Rotate, and Scale. Moving Edit Mode components changes their coordinates but does not move the object's origin. Pivot and transform orientation are independent state. [Move](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/move.html), [Rotate](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/rotate.html), [Scale](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/scale.html), [pivot points](https://docs.blender.org/manual/en/latest/editors/3dview/controls/pivot_point/index.html) + +The first implementation now hides transform arrows while the Select tool is active and uses a smaller Move-only overlay. The complete compact, screen-size-stable transform overlay should add: + +- Thin X/Y/Z stems, small terminal handles, two-axis plane squares, and a small neutral center/view-plane handle. +- The gizmo appears only when components are selected and never visually dominates the mesh. +- The constrained axis brightens during a command; the other axes fade. +- A small value readout near the pivot or contextual HUD shows live distance/angle/scale and typed input. +- `G`/`R`/`S` and pointer-drag gizmos invoke the same pure transform command and preview transaction. + +This is a visual replacement, not a second transform implementation. + +### Axis constraints, numeric input, and modal lifecycle + +During Move/Rotate/Scale and extrusion, `X`, `Y`, or `Z` constrain to one axis; `Shift-X/Y/Z` constrain Move/Scale to the other two axes. Repeating an axis key cycles orientation spaces and then clears the constraint, while the constrained axis is shown brighter. [Blender axis locking](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/control/axis_locking.html) + +Typing during a modal transform supplies an exact value. Blender displays the value in the viewport footer and supports decimal, negative, reciprocal, per-axis, unit, and expression input. The essential v1 subset is signed decimal values with the project unit system; multi-axis and expressions can follow. [Blender numeric input](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/control/numeric_input.html) + +Ordinary modal transforms preview continuously, confirm with click/Return, and cancel back to their original state with right-click/Escape. After confirmation, Adjust Last Operation (`F9`) can reparameterize the result; a new edit after undo truncates redo history. [Blender operators](https://docs.blender.org/manual/en/latest/interface/operators.html), [Undo & Redo](https://docs.blender.org/manual/en/latest/interface/undo_redo.html) + +The implementation rule is: every preview recomputes from an immutable pre-operation topology and parameter object. Pointer movement must never compound the prior preview. + +### Snapping + +Blender separates the **snap base** being moved (Closest, Center/pivot, Median, Active) from the **snap target** (increment/grid, vertex, edge, face, volume, edge center, edge perpendicular, and others). Increment snapping is relative to the starting position unless absolute grid snap is enabled. Face Project and Face Nearest can move vertices individually rather than transform the selection rigidly. [Blender snapping](https://docs.blender.org/manual/en/latest/editors/3dview/controls/snapping.html) + +Pascal should copy this separation without copying Blender's modifier map. The repository already defines visible per-context snap modes, Shift-to-cycle, Ctrl-to-cycle grid step, and Alt force/free. Mesh commands must resolve through that path. Add component targets and snap-base policies behind the same mode UI rather than reading new hidden modifiers. [Pascal interaction scope](architecture/interaction-scope.md), [Pascal tools](architecture/tools.md) + +### Proportional editing + +Proportional Editing affects nearby unselected vertices with a falloff while a selected transform runs. Wheel/PageUp/PageDown adjusts the influence radius live. Connected Only measures distance through topology rather than Euclidean space; Projected from View ignores depth. [Blender proportional editing](https://docs.blender.org/manual/en/latest/editors/3dview/controls/proportional_editing.html) + +This belongs in the shared transform engine as vertex weights and a radius overlay. It is not a brush and does not change topology. + +## Operator behavior and Pascal implications + +### Extrude Region and Individual Faces + +Extrude duplicates selected geometry while keeping it connected. Region extrusion identifies the selection boundary, creates side faces only there, and moves the selected interior patch unchanged; faces initially move along their average normal and can be axis-constrained. Closed and open selections have different connectivity behavior. [Blender Extrude Region](https://docs.blender.org/manual/en/latest/modeling/meshes/tools/extrude_region.html) + +Individual Faces extrudes each face separately rather than treating connected faces as one region. [Blender Extrude Individual Faces](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/face/extrude_individual_faces.html) + +The current `extrude-face` command is a good Push/Pull seed, but the target needs: + +- `extrude-region` over vertex/edge/face selections, with connected-region boundary extraction. +- `extrude-individual-faces` with separate caps and side walls. +- A modal distance preview using average/individual normals, axis constraints, typed input, and snapping. +- Selection/remapping that selects new caps and preserves surviving IDs. + +Blender has a hazardous quirk where cancelling the movement portion of some face extrusions can leave coincident new topology. [Blender Extrude Faces](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/face/extrude_faces.html) Pascal should deliberately diverge: Escape cancels the entire uncommitted extrusion, matching the repository's preview/cancel convention and avoiding invisible duplicate faces. + +### Inset Faces + +Inset creates border faces around selected patches; pointer distance controls thickness, Ctrl adjusts depth, and the command can switch between connected regions and individual faces. Boundary, even/relative offset, edge rail, outset, selection side, and attribute interpolation alter topology or output data. Confirm applies the result; right-click/Escape cancels it. [Blender Inset Faces](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/face/inset_faces.html) + +Pascal should first support planar connected face patches, thickness, optional depth, even offset, and outset. Each disconnected selected patch is a separate inset region inside one command. Preview must rebuild from the pre-inset snapshot whenever any parameter changes. + +### Bevel + +Bevel is a modal topology operator. Pointer movement controls width, Wheel controls segments, typed input is supported, Shift gives fine control, and options change width interpretation, edge/vertex affect, profile, overlap clamping, loop slide, miters, intersections, materials, and normals. Click/Return confirms; right-click/Escape cancels. [Blender Bevel](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/edge/bevel.html) + +For Pascal, v1 edge bevel should require exactly two incident faces, support width, segments, profile, and clamp overlap, and reject unsupported non-manifold junctions explicitly. It is an adjacency-driven topology command, not screen-space line thickening. + +### Loop Cut and Slide + +Loop Cut is explicitly two-stage. Hovering a perpendicular edge previews a yellow topology-derived loop; first click chooses the loop, then pointer movement slides the new loop. Right-click in stage one aborts, but right-click in stage two **commits a centered cut**. Wheel or typed input changes cut count; Even, Flipped, Clamp, smoothing, and UV correction remain parameters. [Blender Loop Cut and Slide](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/edge/loopcut_slide.html) + +Pascal needs quad-loop traversal and a staged modal state. It cannot substitute an arbitrary world plane cut. Loop/ring selection should land first because it proves the required adjacency traversal and pole/branch termination. + +### Subdivide + +Subdivide applies immediately to selected edges/faces, then exposes Number of Cuts, smoothing, n-gon policy, quad-corner pattern, and optional displacement in Adjust Last Operation. Results depend on the selected edge pattern and incident triangle/quad/n-gon topology; subdividing an n-gon's boundary does not necessarily split its face. [Blender Subdivide](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/edge/subdivide.html) + +Pascal needs deterministic pattern handlers and a replayable command record. A simple “split every face into four” implementation would not match Blender's selection semantics. + +### Merge + +Merge supports Center, Cursor, First, Last, per-connected-island Collapse, and By Distance. First/Last depends on selection order, while Collapse requires connected-component grouping. By Distance adds a threshold and optional unselected participation. [Blender Merge](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/mesh/merge.html) + +The command result must choose survivors, resolve positions/attributes, remove degenerate edges/faces, and return a complete remap from removed IDs to survivors. This is why active element and selection order must precede Merge. + +### Delete and Dissolve + +Delete exposes explicit vertex, edge, face, only-edge-and-face, and only-face variants with different dependent-topology cleanup. Dissolve preserves the surrounding surface: vertex dissolve joins surrounding faces, edge dissolve requires exactly two neighboring faces, face dissolve merges connected patches, and Limited Dissolve removes sufficiently flat detail under an angle threshold. [Blender Deleting & Dissolving](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/mesh/delete.html) + +Pascal should not expose one ambiguous `deleteSelected()` command. The UI may default based on component mode, but the pure command must encode the exact delete/dissolve variant and return removed/surviving selection mappings. + +### Knife + +Knife changes the cursor, lets successive clicks or a drag define visible cut paths, previews yellow segments and aqua points, supports multiple paths, measurements, midpoint/geometry snapping, angle/axis constraints, selected-only and visible-only/cut-through policies, internal segment undo, and one final apply or cancel. It is view-dependent and commits the resulting edge chains atomically. [Blender Knife Topology Tool](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/mesh/knife_topology_tool.html) + +Pascal therefore needs a screen-space overlay plus a geometry kernel: raycast the path to faces, sort crossings, split edges/faces, reject unrepresentable paths, and commit once. Knife should come after stable picking, face splitting, adjacency, and command-local undo exist. + +## Target Pascal architecture + +### Persistent topology, not render triangles + +The serialized node should remain one scene node with internal components. Vertices/edges/faces must not become scene nodes. The existing schema is a sound starting point, but a runtime adjacency index should be derived once per topology revision: + +- Vertex → incident edges/faces. +- Edge → endpoint vertices and ordered incident faces. +- Face → ordered boundary edges/vertices. +- Connected components, boundary edges, and loop/ring traversal helpers. + +Future face-corner records must carry UVs, split normals, and other per-corner attributes. Blender's editable BMesh is connectivity-aware, provides split/collapse/dissolve operators and custom-data layers, and explicitly refreshes tessellation after destructive edits. [Blender BMesh API](https://docs.blender.org/api/current/bmesh.html), [BMesh operators](https://docs.blender.org/api/current/bmesh.ops.html) + +`BufferGeometry`, triangle ranges, normals, UVs, bounds, and floor-plan projection are derived caches. Never infer authoritative edges or persistent face identity back from triangulated render positions. + +### Topology invariants and remapping + +Keep the current checks and add, in dependency order: + +1. Finite coordinates; distinct face-loop entries; nonzero edge length; no duplicate face boundary. +2. Nonzero-area, planar, simple face loops and deterministic triangulation success. +3. Consistent winding across shared edges and explicit normal direction. +4. Explicit manifold policy: whether loose vertices/edges and edges with 0, 1, 2, or more incident faces are supported by each command. +5. Attribute validity and interpolation once face-corner data exists. + +Blender documents corresponding editable-mesh invariants: selected edges imply endpoint selection, selected faces imply their edges/vertices, hidden elements are unselected, duplicate edges/faces are invalid, and faces have at least three vertices. [Blender BMesh state](https://docs.blender.org/api/current/bmesh.html) + +Every command result should include more than `topology` and one selection: + +```ts +type MeshCommandResult = { + topology: CustomMeshTopology; + selection: MeshComponentSelection; + active: MeshComponentRef | null; + remap: { + retained: ReadonlySet; + created: ReadonlySet; + removed: ReadonlySet; + replacedBy: ReadonlyMap; + }; + warnings: readonly MeshCommandWarning[]; +}; +``` + +The shape is illustrative. The contract matters: operators must report identity changes so selection, host references, material assignments, and future measurements can follow edits deterministically. + +### Session and modal state + +Move the current local React state into a dedicated editor-owned session controlled through semantic methods, not independent setters: + +```ts +type MeshEditSession = { + nodeId: CustomMeshNodeId; + enabledModes: ReadonlySet<"vertex" | "edge" | "face">; + selected: { + vertices: ReadonlySet; + edges: ReadonlySet; + faces: ReadonlySet; + }; + active: MeshComponentRef | null; + selectionOrder: readonly MeshComponentRef[]; + xray: boolean; + pivot: MeshPivotMode; + orientation: MeshTransformOrientation; + proportional: MeshProportionalSettings; + operation: MeshModalOperation | null; +}; +``` + +The existing global editor `Mode` already contains `'edit'` for property-boundary editing, so it cannot silently become mesh Edit Mode. The current `drafting/custom-mesh-edit` scope also misnames a persistent editing session. + +Recommended seam: + +- `useMeshEditSession` owns the detailed session and immutable operation snapshot. +- Add an explicit `mesh-editing` summary to `InteractionScope` with `nodeId`, `phase` (`selecting` or `operating`), operator, and stage. This keeps selection gating, hot-set, overlays, HUD, and Escape routing on the interaction spine without putting large topology snapshots into it. +- One controller owns enter, switch mode, select, begin operation, preview, confirm, cancel, and exit so the stores cannot drift. +- Viewer selection retains the custom-mesh node; component selection remains editor-only. `packages/viewer` stays unaware of Edit Mode. [Viewer isolation](architecture/viewer-isolation.md), [selection managers](architecture/selection-managers.md), [interaction scope](architecture/interaction-scope.md) + +### Pure command and modal interfaces + +Extend the existing pure `applyCustomMeshCommand` model instead of embedding algorithms in `selection.tsx`: + +```ts +type MeshCommand

= { + kind: string; + execute(input: { + topology: CustomMeshTopology; + selection: MeshComponentSelection; + active: MeshComponentRef | null; + parameters: P; + }): MeshCommandResult; +}; + +type MeshModalOperation

= { + command: MeshCommand

; + baseTopology: CustomMeshTopology; + baseSelection: MeshComponentSelection; + baseActive: MeshComponentRef | null; + parameters: P; + constraint: MeshTransformConstraint; + typedInput: string; + stage: string; +}; +``` + +Commands remain pure, deterministic, Three-free, React-free, and store-free. Gizmos, `G/R/S`, toolbar actions, numeric input, and future touch controls only translate user input into parameters. Each preview calls `execute` against `baseTopology`; it never uses the prior preview as input. + +### Preview, confirm, cancel, history, and redo + +Use the current axis-drag and PolygonEditor pattern for every operator: + +1. `begin`: capture immutable topology/selection/active state and enter the operation scope. +2. `preview`: execute from that snapshot, publish `{ topology }` through `useLiveNodeOverrides`, update component overlays, and `markDirty`; do not call `useScene.updateNode`. +3. `confirm`: validate, clear the override, perform one `updateNode`, apply the returned selection/remap, and return to mesh-selection phase. +4. `cancel`: clear the override and return to the exact pre-operation topology/selection without history. +5. `unmount/blur/tool switch`: run the same cancellation path. + +For Adjust Last Operation, retain `{ baseTopology, baseSelection, commandKind, parameters }` after commit. Parameter changes re-execute from the base and replace the last semantic history entry rather than append incremental edits. This requires explicit history integration and should not be faked with repeated `updateNode` calls. Blender exposes equivalent post-operation parameter editing through the lower-left panel and `F9`. [Blender Undo & Redo](https://docs.blender.org/manual/en/latest/interface/undo_redo.html) + +### Package seams + +| Concern | Home | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Serialized schema, validation shared by every consumer, migrations | `packages/core/src/schema/nodes/custom-mesh.ts` | +| Kind-specific pure adjacency and command kernel | `packages/nodes/src/custom-mesh/`, kept independent of React/stores/Three | +| Derived Three.js geometry and triangle-to-face metadata | `packages/nodes/src/custom-mesh/geometry.ts` through `def.geometry` | +| Kind-owned selection/edit contribution | `packages/nodes/src/custom-mesh/selection.tsx`, progressively reduced to composition over shared editor controllers/components | +| Reusable mesh-edit session, modal input, compact gizmo, numeric HUD, picking | `packages/editor`, injected into Viewer as editor-only children/contributions | +| Read-only rendering/scene registration | Existing generic viewer path; no edit-mode state in `packages/viewer` | + +This follows the registry composition model and viewer isolation. [Node definitions](architecture/node-definitions.md), [viewer isolation](architecture/viewer-isolation.md), [Three.js layers](architecture/layers.md) + +## Phased delivery plan + +### Phase 0 — stabilize the existing slice + +- Keep the current schema, placement, box, derived geometry, materials, floor-plan footprint, translation command, extrusion command, and one-undo axis preview. +- Add the missing topology degeneracy checks and command remap contract before adding more destructive operations. +- Add save/reload, duplicate/delete, floor-plan live-update, cancel/unmount, and history tests. +- Set an initial interactive budget, for example representative fixtures at 100, 1,000, and 10,000 components, and measure validation, triangulation, rebuild, and picking. + +**Exit criteria:** current features survive reload and undo/redo; invalid topology never reaches rendering; repeated preview/cancel leaves no override or history entry. + +### Phase 1 — Blender-like Edit Mode shell and selection + +- Introduce the persistent mesh edit session and explicit interaction-scope summary. +- Support Edit button and Tab entry/exit, with modal Escape precedence. +- Replace always-visible spheres/cylinders with screen-stable Blender-like point/line/face overlays, selected orange and active white. +- Replace the large arrow trio with the compact transform overlay described above. +- Add active element, selection order, topology-aware `1`/`2`/`3` conversion, Shift mixed modes, Select All/None/Invert, box select, and X-Ray. +- Route picking through face-range metadata plus screen-space vertex/edge hit testing; keep editor visuals on `EDITOR_LAYER`. + +**Exit criteria:** component selection is stable across camera angles and render rebuilds; occluded components cannot be picked unless X-Ray is on; active and selected visuals are unambiguous; entering/exiting never changes topology. + +### Phase 2 — shared transform grammar + +- Add one Move/Rotate/Scale engine invoked by compact gizmo and `G/R/S`. +- Add free/view-plane movement, axis and plane constraints, local/global/normal orientations, median and active pivots, signed decimal/unit input, confirm/cancel, and visible values. +- Integrate Pascal snap modes with component targets and explicit snap-base selection. +- Keep one live override and one history commit per gesture. + +**Exit criteria:** equivalent gizmo and keyboard inputs produce byte-identical topology; numeric and pointer previews recompute from the same snapshot; cancel restores exact coordinates and selection. + +### Phase 3 — architectural Push/Pull, region extrusion, and inset + +- Convert the current immediate single-face extrusion into the modal engine. +- Add multi-face Extrude Region, Individual Faces, normal and axis constraints, exact distance, repeated cap extrusion, and full Escape rollback. +- Add planar region Inset with thickness, depth, outset, even offset, and disconnected patches. +- Preserve material slots and define interpolation for all created faces/components. + +**Exit criteria:** the video's measured face Push/Pull flow works without a large arrow; connected regions have no internal duplicate side walls; repeated extrusion keeps stable IDs and valid winding. + +### Phase 4 — cleanup and resolution operators + +- Add explicit Delete variants, Dissolve Vertex/Edge/Face, Merge Center/First/Last/Collapse/By Distance, and Subdivide pattern cases. +- Add selection remap, connected-component utilities, n-gon tests, and last-operator replay for Subdivide parameters. +- Define command-by-command behavior for boundaries and non-manifold inputs. + +**Exit criteria:** every removed component is represented in remap output; no dangling selection remains; delete and dissolve visibly differ; merge/subdivide replay deterministically. + +### Phase 5 — bevel, loops, and knife + +- Add loop/ring traversal and selection first. +- Add edge/vertex Bevel with width, segments, profile, and clamp overlap. +- Add staged Loop Cut & Slide with topology hover preview, cut count, centered right-click commit, and slide. +- Add Knife screen overlay, snapping/constraints, command-local undo, face/edge splitting, and one atomic commit. + +**Exit criteria:** traversal stops predictably at unsupported junctions; bevel rejects or handles degeneracy without corrupting topology; Knife cancel is history-free and confirmed paths survive validation/triangulation. + +### Phase 6 — proportional editing, attributes, and operator redo + +- Add proportional falloffs, live radius, Connected Only graph distance, and Projected from View to the shared transform engine. +- Add face-corner UVs, custom/split normals, attribute interpolation, normal flip/recalculate, and material preservation across every operator. +- Add a real Adjust Last Operation surface and deterministic replacement of the most recent command. +- Revisit multi-object Edit Mode only after single-node semantics and performance are proven. + +## Test strategy + +### Pure topology tests + +- Table-driven fixtures for triangle, quad, concave n-gon, disconnected patches, boundaries, holes when supported, and non-manifold junctions. +- Invariant checks after every command and randomized command sequences. +- Stable-ID/remap assertions: retained IDs stay retained, created IDs never collide, removed IDs never remain selected, replacement maps resolve. +- Determinism: same base topology + selection + parameters produces structurally identical output. +- Attribute/winding/normal assertions as those layers land. + +### Transaction and state tests + +- Enter/select/begin/preview/confirm/cancel/exit transition tests for the mesh session controller and interaction scope. +- Assert pointer-rate preview performs zero scene writes; confirm performs exactly one; cancel performs zero and clears overrides. +- Undo/redo restores topology, component selection policy, materials, and redo truncation correctly. +- Adjust Last Operation re-executes from its original base instead of compounding the prior result. + +### Rendering and picking tests + +- Triangle-to-face mapping for convex and concave faces. +- Depth-tested versus X-Ray picking, face-center targets, screen-space tolerances, and camera-scale stability. +- Geometry/floor-plan parity after live and committed edits. +- Visual regression captures for vertex/edge/face, hovered/selected/active, constrained axes, numeric HUD, proportional radius, loop preview, and knife paths. + +### End-to-end acceptance tests + +- Place a custom mesh, Tab into Edit Mode, select a face, `G Z 1.5`, confirm, undo, redo, and exit. +- Push/Pull a face to an exact height, cancel a second extrusion with no duplicate topology, then repeat and commit. +- Select through with X-Ray, switch component modes with topology-aware preservation, and merge at active/last. +- Inset, bevel, loop cut, dissolve, subdivide, and knife representative meshes as their phases land. +- Blur, Escape, selection changes, route changes, and unmount never strand interaction scope, cursor state, or live overrides. + +## Risks and decisions + +1. **Scope:** “exactly like Blender” is open-ended. Promise the documented phase behaviors, not the full mature Blender surface. +2. **Topology degeneracy:** Inset, bevel, dissolve, n-gon triangulation, and knife have hard numerical cases. Unsupported inputs must fail visibly and atomically. +3. **Performance:** `geometryKey` currently serializes full topology and previews rebuild the full mesh. Benchmark before raising mesh-size promises; coordinate-only preview may later update buffers incrementally while retaining one canonical preview state. +4. **Picking:** Always-on overlay hit volumes become noisy on dense meshes. Screen-space acceleration and depth policy are required, not optional polish. +5. **State:** The current local session can disappear on remount. Centralizing it must not create a second interaction truth beside `useInteractionScope`. +6. **2D/3D parity:** Mesh component editing can be an explicit 3D-only exception because depth, normals, and view-projected cuts are essential. The floor plan must still update live/committed projection; any future 2D component editor must share commands and snapping. [Pascal 2D/3D parity](architecture/tools.md) +7. **Licensing:** Blender is GPL-licensed. Its behavior and manuals can guide an independent implementation, but copying Blender source into this MIT repository requires license review. [Blender license](https://developer.blender.org/docs/license/) diff --git a/wiki/blender-loop-cut-research.md b/wiki/blender-loop-cut-research.md new file mode 100644 index 0000000000..f4f0403ad4 --- /dev/null +++ b/wiki/blender-loop-cut-research.md @@ -0,0 +1,165 @@ +# Blender Loop Cut and Slide Research + +## Purpose and conclusion + +This brief records Blender's Loop Cut and Slide behavior for the Pascal custom-mesh implementation. Behavior claims use only first-party Blender documentation and the official Blender source mirror. The source links are pinned to commit `1663a95e78e36c5a792c63fc10bcd4e1d09b7585`; the research was completed on 2026-08-10. + +The important architectural fact is that Blender does not implement this as one opaque action. `MESH_OT_loopcut_slide` is a macro that runs topology insertion (`MESH_OT_loopcut`) and then the existing edge-slide transform (`TRANSFORM_OT_edge_slide`). [Blender mesh operator registration](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/mesh_ops.cc#L217-L228), [Blender operator API](https://docs.blender.org/api/current/bpy.ops.mesh.html#bpy.ops.mesh.loopcut_slide) + +Pascal should preserve the same conceptual split: + +1. Resolve and preview a valid quad edge ring without mutating topology. +2. Insert a centered loop into an operation-local draft. +3. Slide that draft from the immutable pre-cut topology. +4. Commit the final topology once, or restore the original topology on Pascal cancellation. + +## Blender interaction contract + +### Stage 1: choose the face loop + +Loop Cut and Slide is available in Mesh Edit Mode through **Edge → Loop Cut and Slide** with `Ctrl-R`. After activation, the pointer chooses an edge perpendicular to the desired cut direction. Blender previews the resulting cut across the face loop. `LMB` accepts that ring and advances to slide; `RMB` aborts before inserting geometry. [Blender Loop Cut and Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/loopcut_slide.html) + +The implementation finds the nearest visible edit-mesh edge under the pointer and refreshes its edge-ring preselection on mouse movement. The preview is not a selection side effect. [Blender loop-cut targeting](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L322-L366), [Blender modal update](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L552-L717) + +Blender draws this preview using the theme's primary gizmo color, a one-pixel line, alpha blending, and disabled depth testing. The manuals call it yellow in the operator and magenta in the toolbar tool, so the transferable behavior is a thin, theme-aware, always-legible preview rather than a hard-coded color. [Blender edge-ring preview rendering](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_preselect_edgering.cc#L151-L203), [Blender Loop Cut tool manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/tools/loop.html) + +### Ring traversal and eligible topology + +Loop Cut traverses an **edge ring**, not an edge loop. Starting from the hovered edge, it crosses each quad to the opposite edge and continues in both directions. The loop preview is formed perpendicular to the crossed ring edges. Blender invokes the edge-ring walker with the `BMW_DELIMIT_EDGE_RING_NGONS` delimiter. [Blender loop-cut ring selection](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L99-L154) + +With that delimiter, the walker: + +- traverses only four-sided faces; +- steps from a face edge to its opposite edge; +- walks outward in both directions from the starting edge; +- accepts boundary and manifold edges while traversing; +- stops at non-quads, hidden faces, already visited edges, and non-manifold ambiguity. + +These rules are explicit in the official BMesh walker. The manual presents triangles and n-gons as poles where the face loop terminates. [Blender BMesh edge-ring walker](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/bmesh/intern/bmesh_walkers_impl.cc#L1399-L1578), [Blender Loop Cut tool manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/tools/loop.html) + +Blender contains a rare fallback that can subdivide only the hovered edge when no quad ring is available; its source notes that edge slide then breaks for that case. This is not a good Pascal MVP behavior because it looks like a valid loop preview but cannot provide the promised slide interaction. [Blender single-edge fallback](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L158-L224) + +### Preview and number of cuts + +For `N` cuts, Blender places preview points at fractions `i / (N + 1)` along every crossed ring edge and connects corresponding points across each quad. This yields uniformly spaced parallel previews without mutating the mesh. Vertex ordering is corrected as the ring is traversed so neighboring preview segments do not cross. [Blender edge-ring preview construction](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_preselect_edgering.cc#L205-L347) + +During stage 1, the wheel, numeric input, and `PageUp`/`PageDown` change the number of cuts. `Alt-Wheel` changes smoothness, although the manual warns that smoothness is not previewed at this stage. [Blender Loop Cut and Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/loopcut_slide.html), [Blender loop-cut modal controls](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L560-L697) + +### Stage 2: slide the inserted loop + +After the first confirmation, pointer movement slides the new loop. `LMB` confirms its current location. `RMB` keeps the cut but resets it to the center; it is not an undo of the whole loop cut. [Blender Loop Cut and Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/loopcut_slide.html) + +The ordinary slide is proportional: every new vertex uses the same factor along its crossed edge, regardless of that edge's absolute length. A negative or positive factor moves the loop toward the two opposite neighboring loops. [Blender Edge Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/edge_slide.html) + +The slide options are: + +| Option | Blender behavior | Shortcut | +| ------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| Factor | Relative slide position between the two neighboring loops. | Pointer or numeric input | +| Even | Keeps an even absolute distance from one adjacent loop rather than using the same percentage on every crossed edge. | `E` | +| Flipped | In Even mode, changes which adjacent loop provides the reference side. | `F` | +| Clamp | Keeps the result inside the surrounding edge extents. Disabling it permits movement outside the face-loop boundary. | `C` or `Alt` | +| Control edge | Changes the edge whose length/reference drives Even mode. | `Alt-Wheel` | + +The manual defines these semantics, and Blender's transform operator exposes `value`, `use_even`, `flipped`, `use_clamp`, mirror editing, geometry snapping, and UV correction as distinct properties. [Blender Edge Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/edge_slide.html), [Blender edge-slide operator source](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/transform/transform_ops.cc#L1215-L1253) + +Edge Slide participates in Blender's transform snapping. Transform operations use the current scene snap settings, and `Ctrl` temporarily inverts snapping by default. Therefore Blender does not define one special Loop Cut snap target; it inherits the active transform snap configuration. [Blender transform modal map](https://docs.blender.org/manual/en/5.0/modeling/transform/modal_map.html), [Blender edge-slide application](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/transform/transform_mode_edge_slide.cc#L781-L850) + +### Confirmation and cancellation details + +| State | Confirm | Cancel/reset | +| --------------- | ---------------------------------- | ------------------------------------ | +| Choosing a ring | `LMB` or Enter advances to slide. | `RMB` or Escape exits without a cut. | +| Sliding | `LMB` confirms the current factor. | `RMB` keeps the cut centered. | + +Stage-1 behavior is explicit in Blender's modal source. Stage-2 right-click behavior is explicit in the manual. [Blender loop-cut modal handling](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L572-L609), [Blender Loop Cut and Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/loopcut_slide.html) + +Pascal should deliberately provide an additional unambiguous full-operation cancel during stage 2: Escape restores the immutable pre-cut topology. This is a product inference, not a claim about Blender, and it matches Pascal's existing preview/cancel and single-undo conventions. + +## Safe Pascal MVP + +The current `CustomMeshTopology` stores stable-ID vertices, undirected edges, and ordered face vertex loops, but validation does not establish manifoldness, face planarity, or geometric self-intersection. The MVP should therefore be narrower than Blender's complete BMesh behavior. + +### Supported topology + +- Accept one hovered edge only when it resolves to one deterministic open or closed ring of quads. +- Require each traversed edge to have no more than two incident faces. +- Stop at boundaries and non-quad faces. +- Reject a non-manifold starting edge, branching traversal, repeated face, missing opposite edge, degenerate edge, or any preview that cannot preserve face winding. +- Show no valid cut preview for an unsupported target; do not partially subdivide only the hovered edge. + +This is an implementation inference based on Blender's quad-ring traversal and Pascal's stricter need to preserve a simple persistent topology. + +### Operation state + +Use an explicit modal state machine: + +```text +inactive + -> hovering { edgeId, orderedRing, cutCount } + -> sliding { baseTopology, draftRing, factor } + -> commit | cancel +``` + +- `hovering` is read-only and changes as the pointer crosses eligible edges. +- First `LMB` snapshots the original topology and creates only an operation-local centered draft. +- Pointer movement always recomputes from that snapshot; it never compounds the prior preview. +- Second `LMB` writes one scene update and one undo entry. +- Stage-1 `RMB`/Escape and stage-2 Escape clear preview state without a scene update. +- Stage-2 `RMB` commits the centered draft, matching Blender's visible behavior. +- Whole-node dragging and component transforms remain disabled while this operator owns the mesh-editing interaction scope. + +### Pure topology rewrite + +Represent the resolved ring as ordered crossed edges plus ordered quad faces and consistent per-face orientation. For one centered cut: + +1. Insert one stable-ID vertex on every crossed edge. +2. Replace each crossed edge with two edge segments. +3. Connect the new vertices across every traversed quad. +4. Replace every traversed quad with two winding-preserving quads that retain its material slot. +5. Reuse each inserted vertex and edge between neighboring faces. +6. Reconnect the last segment to the first for a closed ring; terminate at boundary edges for an open ring. +7. Validate the completed draft before exposing or committing it. + +For `N` centered cuts, interpolate at `i / (N + 1)`, split each crossed edge into `N + 1` segments, and replace each crossed quad with `N + 1` ordered quads. Multi-cut should follow the single-cut implementation because its ID remapping, selection, and slide constraints increase failure modes. + +### MVP interaction and visual treatment + +- Add a persistent Loop Cut tool to the existing floating Edit Mode UI and `Ctrl-R` shortcut routing. +- Hover the existing generous edge hit targets; render only the exact prospective loop as a thin project-theme line. +- Default to one cut. Let the wheel change count with a conservative Pascal cap, such as 32, to prevent accidental topology explosions. +- Start slide at the center and support proportional factor with mandatory clamp. +- Snap the factor predictably through Pascal's existing interaction/snap model; do not introduce a second hidden modifier convention solely for this node. +- Display the cut count in stage 1 and the slide factor in stage 2. +- Select the newly created edge loop after commit. + +### Parity deferred until the kernel is proven + +Implement these only after single-cut traversal, winding, preview cancellation, and undo are correct on skewed geometry: + +1. Multi-cut sliding. +2. Even distance, control-edge choice, and Flip. +3. Unclamped slide; it can create self-intersection that the current validator cannot detect. +4. Smoothness and falloff. +5. UV correction and mirror editing, once custom-mesh topology stores the required attributes and symmetry contract. +6. Blender's single-edge fallback on non-quad topology. + +## Acceptance matrix + +| Case | Expected result | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| Default box, hover a vertical edge | Preview one closed horizontal loop across the four side quads. | +| Default box, hover a top/bottom edge | Preview the perpendicular closed ring selected by that edge. | +| Extruded or inset shape containing n-gon poles | Preview traverses deterministic quads and stops before the non-quad. | +| Skewed quads | Preview segments preserve correspondence and do not cross; proportional slide remains on crossed edges. | +| Open quad strip | Preview terminates at both boundaries and commit creates an open new loop. | +| Triangle, isolated edge, or non-manifold branch | No valid preview and no mutation. | +| Wheel in stage 1 | Parallel previews update uniformly; topology remains unchanged. | +| First-stage Escape/RMB | Preview disappears; topology and history are unchanged. | +| Stage-2 pointer movement | Live draft updates from the original topology without accumulating error. | +| Stage-2 Escape | Pascal restores the original topology and adds no history entry. | +| Stage-2 RMB | One centered cut is committed. | +| Stage-2 LMB | Current clamped factor is committed in one undoable update. | +| Rotate/move whole node while tool is active | Node drag does not start. | + +Unit tests should separately cover ring discovery, orientation, open/closed termination, ID uniqueness, material retention, one-cut and multi-cut rewrites, invalid-topology rejection, factor clamping, and validation of every result. Interaction tests should cover hover-without-mutation, both confirmation stages, both cancellation stages, scroll count, selection of created edges, and exactly one history update. diff --git a/wiki/blender-material-assignment-research.md b/wiki/blender-material-assignment-research.md new file mode 100644 index 0000000000..203df408af --- /dev/null +++ b/wiki/blender-material-assignment-research.md @@ -0,0 +1,177 @@ +# Blender Edit-Mode Material Assignment Research + +## Purpose and conclusion + +This brief records Blender's per-face material workflow and translates its useful interaction contracts into a Pascal custom-mesh plan. Blender behavior claims use only the official Blender manual, Python API, and source mirror. Source links are pinned to commit `ce63cce6b7d645d6565f0f973142209b5069a7b2`; the research was completed on 2026-08-12. + +The central design is deliberately two-level: + +1. A reusable **Material** data-block owns the appearance. +2. An object's ordered **material slots** reference materials. +3. Each face stores one slot choice; it does not contain or duplicate the material. + +Pascal already has the corresponding persistent pieces in a safer stable-ID form: `CustomMeshFace.materialSlot` identifies an object-local slot, `CustomMeshNode.slots` maps slot IDs to reusable `MaterialRef` values, and the scene owns reusable materials. The missing product surface is an editor-owned active slot plus explicit face assignment controls. + +## Blender's data model + +Materials are reusable data-blocks that can be assigned to one or more objects. Material slots link those data-blocks to an object/mesh. Blender starts with one slot applying one material to the whole object; multiple slots allow different parts of the mesh to use different materials. [Blender material assignment manual](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#material-slots) + +For a mesh, each polygon has one zero-based `material_index`, documented as the polygon's material-slot index with a default of `0`. It is therefore a many-faces-to-one-slot mapping: every face chooses exactly one slot, while any number of faces can share it. [Blender `MeshPolygon.material_index` API](https://docs.blender.org/api/current/bpy.types.MeshPolygon.html#bpy.types.MeshPolygon.material_index) + +Slot identity and Material identity are different: + +- A slot is an object/mesh-local position in an ordered list. +- A Material is a reusable data-block referenced by a slot. +- The same Material can be reused in other objects and can even occur in more than one slot on an object. Blender's assignment code explicitly prefers the active object's slot index before falling back to searching for a matching Material data-block, because duplicate slot references are possible. [Blender material assignment operator](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/render/render_shading.cc#L310-L343) + +The Material data-block picker is the reuse surface. It lists materials in the current blend file, supports name search, and lets the user place an existing Material in the selected slot instead of duplicating it. [Blender reusing existing materials](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#reusing-existing-materials) + +Blender additionally supports linking slot materials to either a specific object or its shared mesh data. That distinction matters for Blender instances, but Pascal should not copy it unless custom meshes later gain shared editable topology instances: Pascal's existing scene `MaterialRef` plus per-node `slots` mapping already provides the relevant reuse boundary. [Blender material slot link behavior](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#data-block) + +## Edit Mode workflow + +Blender exposes the object material-slot list in Material Properties. In Edit Mode it adds three face-oriented actions below the list: **Assign**, **Select**, and **Deselect**. The documented workflow for applying a second material is: + +1. Begin with the base material covering the object. +2. Enter Edit Mode and Face Select. +3. Select one or many target faces. +4. Add/select a material slot and choose a new or existing Material for it. +5. Press **Assign**. + +[Blender Edit Mode material controls](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#edit-mode), [Blender multiple-material workflow](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#multiple-materials) + +The three actions have distinct semantics: + +| Action | Blender behavior | Important invariant | +| ------------ | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| **Assign** | Writes the active slot index to every selected face. | It overwrites those faces' previous assignments and leaves unselected faces unchanged. | +| **Select** | Selects visible faces whose assignment matches the active slot. | It adds matching faces to the current selection; it does not clear unrelated selected faces. | +| **Deselect** | Deselects visible faces whose assignment matches the active slot. | It subtracts matching faces from the current selection; it does not change any assignment. | + +The manual defines the public actions. The source shows that Assign loops over selected edit-mesh faces and sets `efa->mat_nr`, while Select/Deselect visit matching, non-hidden faces and set only their selection state. [Blender Assign implementation](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/render/render_shading.cc#L344-L408), [Blender material Select/Deselect implementation](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/mesh/editmesh_select.cc#L3450-L3466) + +Assigning multiple selected faces is a single operator and a single undoable action. Repeating Assign with another active slot directly replaces the selected faces' earlier slot index; there is no layered material stack per face. [Blender material assignment operator registration](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/render/render_shading.cc#L400-L408) + +### Active slot synchronization + +When face picking resolves a face, Blender sets the object's active material index from that face's `mat_nr`. This makes the material panel follow the active/clicked face rather than forcing the user to hunt for its slot manually. [Blender face-pick active-material synchronization](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/mesh/editmesh_select.cc#L2918-L2925) + +This does not mean every multi-face selection has one material. A selection can contain faces with mixed assignments; the active face supplies the panel's active slot, and an explicit Assign then makes all selected faces use that slot. + +### Choosing a Material is not Assign + +There are two mutations that should remain conceptually separate: + +- Replacing the Material referenced by an existing slot changes the appearance of **every face already using that slot**. +- Pressing Assign changes the slot choice of **the currently selected faces**. + +Blender's UI makes the distinction through the slot list/data-block chooser and the separate Assign button. Pascal may streamline the number of clicks, but a control that edits a shared slot must not look like a face-scoped assignment. Otherwise changing one selected face could unexpectedly repaint many unselected faces. + +## Removal, cleanup, and the absence of per-face unassign + +Blender does not provide an **Unassign** action for faces. A face is moved back to the base appearance by assigning the base/first slot to it. Deselect only changes selection. + +Removing a material slot is an object-level structural action, not per-face unassignment, and current Blender blocks it during Edit Mode. [Blender material-slot removal poll](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/render/render_shading.cc#L245-L302) + +When a slot is removed in Object Mode, Blender removes that ordered entry and remaps face indices. Higher indices shift down; faces using the removed nonzero slot fall back to the preceding slot, while removing slot zero leaves its faces at index zero so they use the new first slot. This is an implementation consequence of Blender's ordinal indices, not a desirable interaction to copy blindly. [Blender slot removal](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/blenkernel/intern/material.cc#L1461-L1546), [Blender mesh material-index remapping](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/blenkernel/intern/mesh.cc#L1812-L1834) + +Blender distinguishes slot cleanup from deleting reusable materials: + +- **Remove Unused Slots** removes slots not referenced by object geometry. +- **Remove All Materials** clears the active object's slots, but the Material data-blocks remain available in the blend file. +- Unlinking a Material from one object does not destroy it while it has other users; zero-user persistence follows Blender's general data-block lifecycle. + +[Blender slot cleanup](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#slot-list), [Blender deleting a material](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#deleting-a-material), [Blender data-block lifecycle](https://docs.blender.org/manual/en/5.0/files/data_blocks.html) + +## Recommended Pascal contract + +This section is a product inference based on Blender's behavior and Pascal's current model. + +### Persistent state + +Keep Pascal's stable string slot IDs rather than copying Blender's fragile ordered indices: + +```ts +type CustomMeshFace = { + id: string; + vertexIds: string[]; + materialSlot: string; +}; + +type CustomMeshNode = { + topology: { faces: CustomMeshFace[] /* ... */ }; + slots?: Record; +}; +``` + +The initial custom mesh keeps every face on `materialSlot: "body"`. Its `body` slot resolves to one reusable scene/library material, preserving the current one-material default. Adding another material creates a new stable slot ID and maps it to a `MaterialRef`; assigning faces changes only their `materialSlot` string. + +The current schema already has this shape in [`custom-mesh.ts`](../packages/core/src/schema/nodes/custom-mesh.ts). No face should store copied shader/color/texture properties. + +### Transient editor state + +The Edit Mode session should own an `activeMaterialSlotId`. It should not be persisted as scene data and it should remain independent of component selection. + +- Clicking a face makes that face active and synchronizes `activeMaterialSlotId` to its slot. +- Shift-selecting additional faces may produce a mixed-material selection; the active face still drives the displayed slot. +- Manually choosing a slot in the panel changes `activeMaterialSlotId` without repainting anything. +- Assign is enabled only when at least one editable face is selected. +- Assign produces one immutable topology update and one undo entry, regardless of face count. + +### Side-panel shape + +A Blender-derived Pascal panel can be compact: + +1. **Face Material** section visible in custom-mesh Edit Mode, primarily in Face selection mode. +2. Active slot/material preview showing only slots already used by the custom mesh, followed by a compact data-block dropdown for material references already used in the scene plus custom scene materials. The full catalog remains in the global Paint tool instead of being duplicated in this inspector. +3. **Assign to selected** as the explicit mutating action for reusing an existing mesh slot, with the selection count in its label or nearby. +4. **Select faces** and **Deselect faces** for the active slot; these are valuable once models have many faces. +5. Painting a face with the global Paint tool adds or reuses an object slot by `MaterialRef` identity. +6. Deleting a non-body slot remaps its faces to the permanent first slot (`body`) in the same update; deleting a mesh-local slot never deletes the reusable material. + +Painting a face performs “ensure object slot + assign this face” in one undoable command. It never silently replaces the active slot's shared material reference, which would have much broader effects. + +### Material identity and slot deduplication + +For the MVP, one object-local slot per `MaterialRef` is the least surprising policy. If the chosen reusable material already has a slot on the mesh, activate and assign that slot; otherwise create a slot and assign it. Blender permits duplicate slots referencing the same Material, but Pascal has no demonstrated need for duplicate semantic slots yet. + +Do not infer that identical-looking materials are the same. Reuse should be based on `MaterialRef` identity. A later explicit duplicate/copy action can create an independently editable scene material. + +### Topology-operation invariant + +Every topology command must preserve or deterministically derive face assignments: + +- Retained faces keep their `materialSlot`. +- Split/inset faces inherit from their source face unless the operator defines otherwise. +- Extruded caps inherit the source face; new side faces need a documented source/fallback rule. +- Merge/dissolve across mixed materials needs a deterministic active/source-face policy. +- Deleting the last face using a slot does not need to delete the reusable Material; optional slot cleanup is separate. + +This is more important than matching Blender's exact removal remap because custom-mesh commands already operate on stable face identities. + +## MVP acceptance matrix + +| Case | Expected result | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| New custom mesh | All faces resolve through the single `body` slot and show one material. | +| Click a face | The face becomes active/selected and the panel follows its assigned material. | +| Paint a face with a reusable material | One object slot is reused or created; only the painted face points to it. | +| Select several faces, choose an existing mesh slot, Assign | Every selected face points to that slot; unselected faces are unchanged. | +| Selected faces have mixed materials | Panel communicates Mixed while preserving the active face/slot; Assign normalizes only the selection. | +| Change which slot is active | No face appearance changes until Assign. | +| Edit the Material referenced by a slot | Every face/object using that reusable Material updates. | +| Select faces by active material | Matching visible faces are added without clearing unrelated selected faces. | +| Deselect faces by active material | Matching visible faces are removed from selection; assignments are unchanged. | +| Reassign a face to `body` | Face returns to the base material; no null/unassigned state is needed. | +| Undo a 20-face assignment | One undo restores every prior per-face slot assignment. | +| Extrude/inset an assigned face | New faces follow the documented inheritance rule and every slot reference remains valid. | +| Delete a used non-body object slot | Its faces move to `body`, `body` becomes active, and the reusable Material remains available. | + +## Implemented decisions + +1. Pascal exposes the object slot list plus a compact reusable-material dropdown in the custom-mesh inspector. It contains deduplicated material references used by scene node slots and custom scene materials; the full catalog stays in the global Paint tool, which also adds or reuses slots when it paints individual faces. +2. Choosing a reusable material from the compact dropdown assigns it immediately to the selected faces. Choosing an existing object slot remains non-mutating until **Assign**. +3. Mixed selections show **Mixed materials**, while the active face continues to drive the active slot. +4. **Select** and **Deselect** ship with the MVP and modify only transient face selection. +5. `body` remains permanent. Per-item deletion remaps affected faces to `body` and preserves reusable scene materials. +6. Extrude and inset inherit the source face, loop-cut pieces inherit the face they split, and bevel/dissolve use the first adjacent face in stable topology order.