diff --git a/packages/editor/src/components/systems/selection-affordance-manager.tsx b/packages/editor/src/components/systems/selection-affordance-manager.tsx index 61ad52241a..0ae886f4b0 100644 --- a/packages/editor/src/components/systems/selection-affordance-manager.tsx +++ b/packages/editor/src/components/systems/selection-affordance-manager.tsx @@ -1,9 +1,18 @@ 'use client' -import { type AnyNodeId, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + createSceneApi, + runAsSingleSceneHistoryStep, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { type ComponentType, Suspense, useMemo } from 'react' import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch' +import type { + SelectionAffordanceHistoryApi, + SelectionAffordanceProps, +} from './selection-affordance-services' /** * Editor-mounted dispatcher for a kind's selection-time editing UI. @@ -19,20 +28,43 @@ import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch' */ export function SelectionAffordanceManager() { const selectedIds = useViewer((s) => s.selection.selectedIds) - const selectedKind = useScene((s) => { + const selectedNode = useScene((s) => { if (selectedIds.length !== 1) return null - return s.nodes[selectedIds[0] as AnyNodeId]?.type ?? null + return s.nodes[selectedIds[0] as AnyNodeId] ?? null }) + const readOnly = useScene((s) => s.readOnly) + const sceneApi = useMemo(() => createSceneApi(useScene), []) + const historyApi = useMemo( + () => ({ + depth: () => useScene.temporal.getState().pastStates.length, + replaceLatest: (expectedDepth, replace) => { + if (useScene.temporal.getState().pastStates.length !== expectedDepth) return false + let replaced = false + runAsSingleSceneHistoryStep(useScene, () => { + useScene.temporal.getState().undo() + replaced = replace() + if (!replaced) useScene.temporal.getState().redo() + }) + return replaced + }, + }), + [], + ) - const Component = useMemo(() => { - if (!selectedKind) return null - return getRegistryAffordanceTool(selectedKind, 'selection') - }, [selectedKind]) + const Component = useMemo | null>(() => { + if (!selectedNode) return null + return getRegistryAffordanceTool(selectedNode.type, 'selection') + }, [selectedNode]) - if (!Component) return null + if (!(Component && selectedNode)) return null return ( - + ) } diff --git a/packages/editor/src/components/systems/selection-affordance-services.ts b/packages/editor/src/components/systems/selection-affordance-services.ts new file mode 100644 index 0000000000..a74b731a0e --- /dev/null +++ b/packages/editor/src/components/systems/selection-affordance-services.ts @@ -0,0 +1,13 @@ +import type { AnyNode, SceneApi } from '@pascal-app/core' + +export type SelectionAffordanceHistoryApi = { + depth: () => number + replaceLatest: (expectedDepth: number, replace: () => boolean) => boolean +} + +export type SelectionAffordanceProps = { + historyApi: SelectionAffordanceHistoryApi + node: AnyNode + readOnly: boolean + sceneApi: SceneApi +} diff --git a/packages/editor/src/components/tools/item/block-preview.test.ts b/packages/editor/src/components/tools/item/block-preview.test.ts index 1884da461e..5be496cea2 100644 --- a/packages/editor/src/components/tools/item/block-preview.test.ts +++ b/packages/editor/src/components/tools/item/block-preview.test.ts @@ -39,10 +39,10 @@ describe('resolveBlockFaceSwitch', () => { }) describe('shouldDetachBlockFaceOnLeave', () => { - test('keeps attached items on the last valid block face during transient leave events', () => { - expect(shouldDetachBlockFaceOnLeave('wall')).toBe(false) - expect(shouldDetachBlockFaceOnLeave('wall-side')).toBe(false) - expect(shouldDetachBlockFaceOnLeave('ceiling')).toBe(false) + test('detaches attached items when they leave a block face', () => { + expect(shouldDetachBlockFaceOnLeave('wall')).toBe(true) + expect(shouldDetachBlockFaceOnLeave('wall-side')).toBe(true) + expect(shouldDetachBlockFaceOnLeave('ceiling')).toBe(true) }) test('allows free floor items to leave a block face', () => { diff --git a/packages/editor/src/components/tools/item/block-preview.ts b/packages/editor/src/components/tools/item/block-preview.ts index 871824356b..88fb3cb12a 100644 --- a/packages/editor/src/components/tools/item/block-preview.ts +++ b/packages/editor/src/components/tools/item/block-preview.ts @@ -35,7 +35,12 @@ export function resolveBlockFaceSwitch( } export function shouldDetachBlockFaceOnLeave(attachTo: string | undefined): boolean { - return !attachTo + return ( + attachTo === undefined || + attachTo === 'wall' || + attachTo === 'wall-side' || + attachTo === 'ceiling' + ) } export function clampBlockFacePosition( diff --git a/packages/editor/src/hooks/use-keyboard.test.ts b/packages/editor/src/hooks/use-keyboard.test.ts index 1902f88df0..86f62acf75 100644 --- a/packages/editor/src/hooks/use-keyboard.test.ts +++ b/packages/editor/src/hooks/use-keyboard.test.ts @@ -8,7 +8,11 @@ import { } from '@pascal-app/core' import { meshEditScope } from '../lib/interaction/scope' import useInteractionScope from '../store/use-interaction-scope' -import { runHistoryShortcut } from './use-keyboard' +import { + canCycleSnappingModeShortcut, + canRunGlobalRotationShortcut, + runHistoryShortcut, +} from './use-keyboard' type RafFn = (callback: (time: number) => void) => number ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( @@ -42,6 +46,20 @@ afterEach(() => { }) describe('history shortcuts during block editing', () => { + test('reserves R for the active mesh editor', () => { + expect(canRunGlobalRotationShortcut()).toBe(true) + useInteractionScope.getState().begin(meshEditScope(NODE_ID)) + expect(canRunGlobalRotationShortcut()).toBe(false) + }) + + test('reserves held Shift for precision while a mesh operation is active', () => { + useInteractionScope.getState().begin(meshEditScope(NODE_ID)) + expect(canCycleSnappingModeShortcut(true)).toBe(true) + + useInteractionScope.getState().begin(meshEditScope(NODE_ID, 'operating', 'translate')) + expect(canCycleSnappingModeShortcut(true)).toBe(false) + }) + test('undoes and redoes mesh changes without leaving component selection mode', () => { useInteractionScope.getState().begin(meshEditScope(NODE_ID)) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index a8479cdfbb..b100f55663 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -171,6 +171,14 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => { return true } +export const canRunGlobalRotationShortcut = () => + useInteractionScope.getState().scope.kind !== 'mesh-editing' + +export const canCycleSnappingModeShortcut = (hasActiveContext = getActiveSnapContext() != null) => { + const scope = useInteractionScope.getState().scope + return hasActiveContext && !(scope.kind === 'mesh-editing' && scope.phase === 'operating') +} + export const useKeyboard = ({ isVersionPreviewMode = false, disabled = false, @@ -200,7 +208,6 @@ export const useKeyboard = ({ // every node move (including wall-hosted items + door/window openings, which // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys // never silently stop working. Force-place lives on Alt where a tool supports it. - const isSnappingCycleContext = () => getActiveSnapContext() != null // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in // between) cycles the grid step — same context as the Shift snapping-mode // cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone @@ -270,7 +277,7 @@ export const useKeyboard = ({ return } - if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) { + if (e.key === 'Shift' && !e.repeat && canCycleSnappingModeShortcut()) { // Cycle the global snapping mode (grid → lines → angles → off). // `'off'` is the snap bypass now, so Shift no longer holds-to-bypass. e.preventDefault() @@ -480,7 +487,8 @@ export const useKeyboard = ({ !e.metaKey && !e.ctrlKey && !isVersionPreviewMode && - !isPlacingOpening() + !isPlacingOpening() && + canRunGlobalRotationShortcut() ) { // `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead // of rotating/flipping the selected node. @@ -699,7 +707,7 @@ export const useKeyboard = ({ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return } - if (!isSnappingCycleContext()) return + if (!canCycleSnappingModeShortcut()) return // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05). useEditor.getState().cycleGridSnapStep() sfxEmitter.emit('sfx:grid-snap') diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 84ddb9f193..b9d528e32c 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -99,6 +99,10 @@ export { buildSvgArrowHeadPoints, getArcPlanPoint, } from './components/editor-2d/svg-paths' +export type { + SelectionAffordanceHistoryApi, + SelectionAffordanceProps, +} from './components/systems/selection-affordance-services' // Phase 5 Stage D transitional exports — pure drafting / angle helpers // consumed by kind-owned drag actions in @pascal-app/nodes. Stage F // cleanup moves these into @pascal-app/nodes (fence/drafting.ts + diff --git a/packages/nodes/src/block/commands.test.ts b/packages/nodes/src/block/commands.test.ts index 788f9a0635..034aa11f75 100644 --- a/packages/nodes/src/block/commands.test.ts +++ b/packages/nodes/src/block/commands.test.ts @@ -6,8 +6,8 @@ describe('applyBlockCommand', () => { test('extrudes a face while retaining valid stable topology', () => { const topology = createBoxBlockTopology() const result = applyBlockCommand(topology, { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) @@ -28,16 +28,16 @@ describe('applyBlockCommand', () => { test('can extrude the resulting cap again without colliding IDs', () => { const first = applyBlockCommand(createBoxBlockTopology(), { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) expect(first.ok).toBe(true) if (!first.ok) return const second = applyBlockCommand(first.topology, { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) expect(second.ok).toBe(true) @@ -61,8 +61,8 @@ describe('applyBlockCommand', () => { ) const originalFaceIds = new Set(topology.faces.map((face) => face.id)) const result = applyBlockCommand(topology, { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) @@ -75,12 +75,37 @@ describe('applyBlockCommand', () => { expect(inheritedFaces.every((face) => face.materialSlot === 'accent')).toBe(true) }) + test('extrudes a connected face region without walls along its internal edges', () => { + const base = createBoxBlockTopology() + const first = applyBlockCommand(base, { + type: 'extrude-faces', + faceIds: ['f-top'], + distance: 0.25, + }) + expect(first.ok).toBe(true) + if (!first.ok) return + const originalFaceIds = new Set(base.faces.map((face) => face.id)) + const sideFace = first.topology.faces.find((face) => !originalFaceIds.has(face.id))! + + const result = applyBlockCommand(first.topology, { + type: 'extrude-faces', + faceIds: ['f-top', sideFace.id], + distance: 0.25, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.faces).toHaveLength(16) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top', sideFace.id] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + test('reports an invalid face selection without changing topology', () => { const topology = createBoxBlockTopology() expect( applyBlockCommand(topology, { - type: 'extrude-face', - faceId: 'missing', + type: 'extrude-faces', + faceIds: ['missing'], distance: 0.25, }), ).toEqual({ ok: false, error: 'Face not found: missing' }) @@ -156,8 +181,8 @@ describe('applyBlockCommand', () => { test('insets a face into a valid inner face and surrounding ring', () => { const result = applyBlockCommand(createBoxBlockTopology(), { - type: 'inset-face', - faceId: 'f-top', + type: 'inset-faces', + faceIds: ['f-top'], amount: 0.2, depth: 0, }) @@ -177,8 +202,8 @@ describe('applyBlockCommand', () => { ) const originalFaceIds = new Set(topology.faces.map((face) => face.id)) const result = applyBlockCommand(topology, { - type: 'inset-face', - faceId: 'f-top', + type: 'inset-faces', + faceIds: ['f-top'], amount: 0.2, depth: 0, }) @@ -192,6 +217,23 @@ describe('applyBlockCommand', () => { expect(inheritedFaces.every((face) => face.materialSlot === 'accent')).toBe(true) }) + test('insets multiple selected faces in one command and keeps every new cap selected', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'inset-faces', + faceIds: ['f-top', 'f-bottom'], + amount: 0.2, + depth: 0, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.vertices).toHaveLength(16) + expect(result.topology.edges).toHaveLength(28) + expect(result.topology.faces).toHaveLength(14) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top', 'f-bottom'] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + test('deletes selected faces, edges, or vertices without invalid references', () => { for (const selection of [ { mode: 'face' as const, ids: ['f-top'] }, @@ -209,6 +251,33 @@ describe('applyBlockCommand', () => { } }) + test('deletes multiple components according to the active component mode', () => { + for (const selection of [ + { mode: 'face' as const, ids: ['f-top', 'f-bottom'] }, + { mode: 'edge' as const, ids: ['e0', 'e6'] }, + { mode: 'vertex' as const, ids: ['v0', 'v6'] }, + ]) { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'delete-components', + selection, + }) + + expect(result.ok).toBe(true) + if (!result.ok) continue + expect(result.selection).toEqual({ mode: selection.mode, ids: [] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + if (selection.mode === 'face') { + expect(result.topology.faces.some((face) => selection.ids.includes(face.id))).toBe(false) + } else if (selection.mode === 'edge') { + expect(result.topology.edges.some((edge) => selection.ids.includes(edge.id))).toBe(false) + } else { + expect(result.topology.vertices.some((vertex) => selection.ids.includes(vertex.id))).toBe( + false, + ) + } + } + }) + test('merges selected vertices at their center and collapses duplicate boundaries', () => { const result = applyBlockCommand(createBoxBlockTopology(), { type: 'merge-vertices', @@ -219,17 +288,32 @@ describe('applyBlockCommand', () => { if (!result.ok) return expect(result.topology.vertices).toHaveLength(7) expect(result.topology.edges).toHaveLength(11) - expect(result.selection).toEqual({ mode: 'vertex', ids: ['v4'] }) - expect(result.topology.vertices.find((vertex) => vertex.id === 'v4')?.position).toEqual([ + expect(result.selection).toEqual({ mode: 'vertex', ids: ['v5'] }) + expect(result.topology.vertices.find((vertex) => vertex.id === 'v5')?.position).toEqual([ 0, 2.4, -1, ]) expect(inspectBlockTopology(result.topology)).toEqual([]) }) + test('merges multiple vertices while retaining the last-selected active vertex ID', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'merge-vertices', + vertexIds: ['v4', 'v5', 'v6'], + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.selection).toEqual({ mode: 'vertex', ids: ['v6'] }) + expect(result.topology.vertices.some((vertex) => vertex.id === 'v6')).toBe(true) + expect(result.topology.vertices.some((vertex) => vertex.id === 'v4')).toBe(false) + expect(result.topology.vertices.some((vertex) => vertex.id === 'v5')).toBe(false) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + test('dissolves a shared edge into one valid face loop', () => { const result = applyBlockCommand(createBoxBlockTopology(), { - type: 'dissolve-edge', - edgeId: 'e4', + type: 'dissolve-edges', + edgeIds: ['e4'], }) expect(result.ok).toBe(true) @@ -258,8 +342,8 @@ describe('applyBlockCommand', () => { : face, ) const result = applyBlockCommand(topology, { - type: 'dissolve-edge', - edgeId: 'e4', + type: 'dissolve-edges', + edgeIds: ['e4'], }) expect(result.ok).toBe(true) @@ -268,6 +352,34 @@ describe('applyBlockCommand', () => { expect(result.topology.faces.some((face) => face.id === 'f-front')).toBe(false) }) + test('dissolves multiple selected edges in one valid transaction', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'dissolve-edges', + edgeIds: ['e4', 'e6'], + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.edges).toHaveLength(10) + expect(result.topology.faces).toHaveLength(4) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top'] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + + test('dissolves the internal boundaries of a selected face region', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'dissolve-faces', + faceIds: ['f-top', 'f-front', 'f-back'], + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.edges).toHaveLength(10) + expect(result.topology.faces).toHaveLength(4) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top'] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + test('cuts a connected quad ring and selects the inserted loop', () => { const result = applyBlockCommand(createBoxBlockTopology(), { type: 'loop-cut', @@ -326,8 +438,8 @@ describe('applyBlockCommand', () => { test('stops a loop cut cleanly before a non-quad face', () => { const dissolved = applyBlockCommand(createBoxBlockTopology(), { - type: 'dissolve-edge', - edgeId: 'e4', + type: 'dissolve-edges', + edgeIds: ['e4'], }) expect(dissolved.ok).toBe(true) if (!dissolved.ok) return @@ -381,8 +493,8 @@ describe('applyBlockCommand', () => { test('bevels a manifold box edge with width, segments, profile, and overlap clamping', () => { const result = applyBlockCommand(createBoxBlockTopology(), { - type: 'bevel-edge', - edgeId: 'e0', + type: 'bevel-edges', + edgeIds: ['e0'], width: 0.2, segments: 3, profile: 0.5, @@ -416,8 +528,8 @@ describe('applyBlockCommand', () => { ) const originalFaceIds = new Set(topology.faces.map((face) => face.id)) const result = applyBlockCommand(topology, { - type: 'bevel-edge', - edgeId: 'e0', + type: 'bevel-edges', + edgeIds: ['e0'], width: 0.2, segments: 3, profile: 0.5, @@ -431,4 +543,40 @@ describe('applyBlockCommand', () => { expect(bevelBands.every((face) => face.materialSlot === 'bottom')).toBe(true) expect(result.topology.faces.find((face) => face.id === 'f-front')?.materialSlot).toBe('front') }) + + test('bevels multiple independent selected edges in one command', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'bevel-edges', + edgeIds: ['e0', 'e6'], + width: 0.2, + segments: 3, + profile: 0.5, + clampOverlap: true, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.faces).toHaveLength(12) + expect(result.selection.mode).toBe('edge') + expect(result.selection.ids).toHaveLength(8) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + + test('bevels adjacent selected edges after remapping their changed corner endpoints', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'bevel-edges', + edgeIds: ['e0', 'e1'], + width: 0.2, + segments: 3, + profile: 0.5, + clampOverlap: true, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.faces).toHaveLength(12) + expect(result.selection.mode).toBe('edge') + expect(result.selection.ids.length).toBeGreaterThan(0) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) }) diff --git a/packages/nodes/src/block/commands.ts b/packages/nodes/src/block/commands.ts index de0e13a252..26fda9f8ae 100644 --- a/packages/nodes/src/block/commands.ts +++ b/packages/nodes/src/block/commands.ts @@ -16,8 +16,8 @@ type Point = [number, number, number] export type BlockCommand = | { - type: 'extrude-face' - faceId: string + type: 'extrude-faces' + faceIds: string[] distance: number } | { @@ -39,8 +39,8 @@ export type BlockCommand = factors: Point } | { - type: 'inset-face' - faceId: string + type: 'inset-faces' + faceIds: string[] amount: number depth: number } @@ -53,8 +53,12 @@ export type BlockCommand = vertexIds: string[] } | { - type: 'dissolve-edge' - edgeId: string + type: 'dissolve-edges' + edgeIds: string[] + } + | { + type: 'dissolve-faces' + faceIds: string[] } | { type: 'loop-cut' @@ -63,8 +67,8 @@ export type BlockCommand = cuts?: number } | { - type: 'bevel-edge' - edgeId: string + type: 'bevel-edges' + edgeIds: string[] width: number segments: number profile: number @@ -503,12 +507,18 @@ function rebuildEdgesFromFaces( return edges } -function bevelEdge( +type BevelParameters = Pick< + Extract, + 'width' | 'segments' | 'profile' | 'clampOverlap' +> + +function bevelOneEdge( topology: BlockTopology, - command: Extract, + edgeId: string, + command: BevelParameters, ): BlockCommandResult { - const edge = topology.edges.find((entry) => entry.id === command.edgeId) - if (!edge) return { ok: false, error: `Edge not found: ${command.edgeId}` } + const edge = topology.edges.find((entry) => entry.id === edgeId) + if (!edge) return { ok: false, error: `Edge not found: ${edgeId}` } const segments = Math.floor(command.segments) if (!Number.isFinite(command.width) || command.width <= 0) return { ok: false, error: 'Bevel width must be positive' } @@ -708,21 +718,115 @@ function bevelEdge( } } -function extrudeFace( +function bevelEdges( topology: BlockTopology, - command: Extract, + command: Extract, ): BlockCommandResult { - const faceIndex = topology.faces.findIndex((face) => face.id === command.faceId) - const face = topology.faces[faceIndex] - if (!face) return { ok: false, error: `Face not found: ${command.faceId}` } + const edgeIds = [...new Set(command.edgeIds)] + if (edgeIds.length === 0) return { ok: false, error: 'Select an edge to bevel' } + const selectedEdges = edgeIds.map((id) => topology.edges.find((edge) => edge.id === id)) + const missingIndex = selectedEdges.findIndex((edge) => !edge) + if (missingIndex >= 0) return { ok: false, error: `Edge not found: ${edgeIds[missingIndex]}` } + const originalVertexById = new Map( + topology.vertices.map((vertex) => [vertex.id, vertex.position]), + ) + const originalSegments = new Map( + selectedEdges.map((edge) => [ + edge!.id, + [ + originalVertexById.get(edge!.vertexIds[0])!, + originalVertexById.get(edge!.vertexIds[1])!, + ] as [Point, Point], + ]), + ) + + let current = topology + const selectedResultIds: string[] = [] + for (const edgeId of edgeIds) { + const originalSegment = originalSegments.get(edgeId)! + const vertexById = new Map(current.vertices.map((vertex) => [vertex.id, vertex.position])) + const distance = (left: Point, right: Point) => + Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]) + const remappedEdge = + current.edges.find((edge) => edge.id === edgeId) ?? + current.edges.reduce<{ edge: BlockEdge; score: number } | null>((best, edge) => { + const start = vertexById.get(edge.vertexIds[0])! + const end = vertexById.get(edge.vertexIds[1])! + const score = Math.min( + distance(start, originalSegment[0]) + distance(end, originalSegment[1]), + distance(start, originalSegment[1]) + distance(end, originalSegment[0]), + ) + return !best || score < best.score ? { edge, score } : best + }, null)?.edge + if (!remappedEdge) return { ok: false, error: `Could not remap bevel edge: ${edgeId}` } + const result = bevelOneEdge(current, remappedEdge.id, command) + if (!result.ok) return result + current = result.topology + selectedResultIds.push(...result.selection.ids) + } + const survivingIds = new Set(current.edges.map((edge) => edge.id)) + return { + ok: true, + topology: current, + selection: { + mode: 'edge', + ids: [...new Set(selectedResultIds)].filter((id) => survivingIds.has(id)), + }, + } +} + +function extrudeFaces( + topology: BlockTopology, + command: Extract, +): BlockCommandResult { + const selectedFaceIds = new Set(command.faceIds) + const selectedFaces = topology.faces.filter((face) => selectedFaceIds.has(face.id)) + if (selectedFaces.length !== selectedFaceIds.size || selectedFaces.length === 0) { + const missing = command.faceIds.find((id) => !topology.faces.some((face) => face.id === id)) + return { ok: false, error: missing ? `Face not found: ${missing}` : 'Select a face to extrude' } + } if (!Number.isFinite(command.distance) || Math.abs(command.distance) < 1e-6) { return { ok: false, error: 'Extrude distance must be a non-zero finite number', } } - const normal = blockFaceNormal(topology, face) - if (!normal) return { ok: false, error: `Face has no usable normal: ${face.id}` } + const edgeKeysByFace = new Map( + selectedFaces.map((face) => [ + face.id, + face.vertexIds.map((id, index) => + blockUndirectedEdgeKey(id, face.vertexIds[(index + 1) % face.vertexIds.length]!), + ), + ]), + ) + const connected = new Set([selectedFaces[0]!.id]) + const queue = [selectedFaces[0]!.id] + while (queue.length > 0) { + const faceId = queue.shift()! + const keys = new Set(edgeKeysByFace.get(faceId)) + for (const candidate of selectedFaces) { + if (connected.has(candidate.id)) continue + if (edgeKeysByFace.get(candidate.id)!.some((key) => keys.has(key))) { + connected.add(candidate.id) + queue.push(candidate.id) + } + } + } + if (connected.size !== selectedFaces.length) { + return { ok: false, error: 'Extrude Region requires connected faces' } + } + const normals = selectedFaces.map((face) => blockFaceNormal(topology, face)) + if (normals.some((normal) => !normal)) { + const invalidFace = selectedFaces[normals.findIndex((normal) => !normal)]! + return { ok: false, error: `Face has no usable normal: ${invalidFace.id}` } + } + const normal = normalize( + normals.reduce( + (sum, value) => [sum[0] + value![0], sum[1] + value![1], sum[2] + value![2]], + [0, 0, 0], + ), + ) + if (!normal) return { ok: false, error: 'Selected face normals cancel each other out' } const verticesById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex])) const allocateVertexId = nextNumericId( @@ -740,12 +844,13 @@ function extrudeFace( const duplicateIds = new Map() const newVertices: BlockVertex[] = [] - for (const vertexId of face.vertexIds) { + const selectedVertexIds = new Set(selectedFaces.flatMap((face) => face.vertexIds)) + for (const vertexId of selectedVertexIds) { const vertex = verticesById.get(vertexId) if (!vertex) return { ok: false, - error: `Face references missing vertex: ${vertexId}`, + error: `Selected face references missing vertex: ${vertexId}`, } const id = allocateVertexId() duplicateIds.set(vertexId, id) @@ -759,36 +864,52 @@ function extrudeFace( }) } - const capVertexIds = face.vertexIds.map((vertexId) => duplicateIds.get(vertexId)!) - const newEdges: BlockEdge[] = [] + const boundaryByKey = new Map< + string, + { count: number; a: string; b: string; source: BlockFace } + >() + for (const face of selectedFaces) { + for (let index = 0; index < face.vertexIds.length; index += 1) { + const a = face.vertexIds[index]! + const b = face.vertexIds[(index + 1) % face.vertexIds.length]! + const key = blockUndirectedEdgeKey(a, b) + const boundary = boundaryByKey.get(key) + if (boundary) boundary.count += 1 + else boundaryByKey.set(key, { count: 1, a, b, source: face }) + } + } const sideFaces: BlockFace[] = [] - for (let index = 0; index < face.vertexIds.length; index += 1) { - const a = face.vertexIds[index]! - const b = face.vertexIds[(index + 1) % face.vertexIds.length]! + for (const boundary of boundaryByKey.values()) { + if (boundary.count !== 1) continue + const { a, b, source } = boundary const newA = duplicateIds.get(a)! const newB = duplicateIds.get(b)! - newEdges.push({ id: allocateEdgeId(), vertexIds: [newA, newB] }) - newEdges.push({ id: allocateEdgeId(), vertexIds: [a, newA] }) sideFaces.push({ id: allocateFaceId(), vertexIds: [a, b, newB, newA], - materialSlot: face.materialSlot, + materialSlot: source.materialSlot, }) } - const faces = topology.faces.slice() - faces[faceIndex] = { ...face, vertexIds: capVertexIds } + const faces = [ + ...topology.faces.map((face) => + selectedFaceIds.has(face.id) + ? { ...face, vertexIds: face.vertexIds.map((id) => duplicateIds.get(id)!) } + : face, + ), + ...sideFaces, + ] const nextTopology: BlockTopology = { vertices: [...topology.vertices, ...newVertices], - edges: [...topology.edges, ...newEdges], - faces: [...faces, ...sideFaces], + edges: rebuildEdgesFromFaces(topology, faces, allocateEdgeId), + faces, } const issues = inspectBlockTopology(nextTopology) if (issues.length > 0) return { ok: false, error: issues[0]!.message } return { ok: true, topology: nextTopology, - selection: { mode: 'face', ids: [face.id] }, + selection: { mode: 'face', ids: selectedFaces.map((face) => face.id) }, } } @@ -922,20 +1043,22 @@ function scaleComponents( ]) } -function insetFace( +function insetOneFace( topology: BlockTopology, - command: Extract, + faceId: string, + amount: number, + depth: number, ): BlockCommandResult { - const faceIndex = topology.faces.findIndex((face) => face.id === command.faceId) + const faceIndex = topology.faces.findIndex((face) => face.id === faceId) const face = topology.faces[faceIndex] - if (!face) return { ok: false, error: `Face not found: ${command.faceId}` } - if (!Number.isFinite(command.amount) || command.amount <= 0 || command.amount >= 1) { + if (!face) return { ok: false, error: `Face not found: ${faceId}` } + if (!Number.isFinite(amount) || amount <= 0 || amount >= 1) { return { ok: false, error: 'Inset amount must be greater than 0 and less than 1', } } - if (!Number.isFinite(command.depth)) return { ok: false, error: 'Inset depth must be finite' } + if (!Number.isFinite(depth)) return { ok: false, error: 'Inset depth must be finite' } const centroid = blockFaceCentroid(topology, face) const normal = blockFaceNormal(topology, face) if (!(centroid && normal)) return { ok: false, error: `Face cannot be inset: ${face.id}` } @@ -967,15 +1090,9 @@ function insetFace( newVertices.push({ id, position: [ - vertex.position[0] + - (centroid[0] - vertex.position[0]) * command.amount + - normal[0] * command.depth, - vertex.position[1] + - (centroid[1] - vertex.position[1]) * command.amount + - normal[1] * command.depth, - vertex.position[2] + - (centroid[2] - vertex.position[2]) * command.amount + - normal[2] * command.depth, + vertex.position[0] + (centroid[0] - vertex.position[0]) * amount + normal[0] * depth, + vertex.position[1] + (centroid[1] - vertex.position[1]) * amount + normal[1] * depth, + vertex.position[2] + (centroid[2] - vertex.position[2]) * amount + normal[2] * depth, ], }) } @@ -1011,6 +1128,24 @@ function insetFace( } } +function insetFaces( + topology: BlockTopology, + command: Extract, +): BlockCommandResult { + const faceIds = [...new Set(command.faceIds)] + if (faceIds.length === 0) return { ok: false, error: 'Select a face to inset' } + const missing = faceIds.find((id) => !topology.faces.some((face) => face.id === id)) + if (missing) return { ok: false, error: `Face not found: ${missing}` } + + let current = topology + for (const faceId of faceIds) { + const result = insetOneFace(current, faceId, command.amount, command.depth) + if (!result.ok) return result + current = result.topology + } + return { ok: true, topology: current, selection: { mode: 'face', ids: faceIds } } +} + function deleteComponents( topology: BlockTopology, command: Extract, @@ -1063,7 +1198,9 @@ function mergeVertices( const selectedVertices = topology.vertices.filter((vertex) => selected.has(vertex.id)) if (selectedVertices.length < 2) return { ok: false, error: 'Select at least two vertices to merge' } - const keepId = selectedVertices[0]!.id + const keepId = [...command.vertexIds] + .reverse() + .find((id) => selectedVertices.some((v) => v.id === id))! const center = selectedVertices.reduce( (sum, vertex) => [ sum[0] + vertex.position[0], @@ -1151,12 +1288,9 @@ function longFacePath(face: BlockFace, start: string, end: string): string[] | n return backward.at(-1) === end && backward.length > 2 ? backward : null } -function dissolveEdge( - topology: BlockTopology, - command: Extract, -): BlockCommandResult { - const edge = topology.edges.find((entry) => entry.id === command.edgeId) - if (!edge) return { ok: false, error: `Edge not found: ${command.edgeId}` } +function dissolveOneEdge(topology: BlockTopology, edgeId: string): BlockCommandResult { + const edge = topology.edges.find((entry) => entry.id === edgeId) + if (!edge) return { ok: false, error: `Edge not found: ${edgeId}` } const [a, b] = edge.vertexIds const adjacentFaces = topology.faces.filter((face) => faceContainsEdge(face, a, b)) if (adjacentFaces.length !== 2) { @@ -1195,6 +1329,65 @@ function dissolveEdge( } } +function dissolveEdges( + topology: BlockTopology, + command: Extract, +): BlockCommandResult { + const edgeIds = [...new Set(command.edgeIds)] + if (edgeIds.length === 0) return { ok: false, error: 'Select an edge to dissolve' } + const missing = edgeIds.find((id) => !topology.edges.some((edge) => edge.id === id)) + if (missing) return { ok: false, error: `Edge not found: ${missing}` } + + let current = topology + const resultFaceIds: string[] = [] + for (const edgeId of edgeIds) { + const result = dissolveOneEdge(current, edgeId) + if (!result.ok) return result + current = result.topology + resultFaceIds.push(...result.selection.ids) + } + const survivingFaceIds = new Set(current.faces.map((face) => face.id)) + return { + ok: true, + topology: current, + selection: { + mode: 'face', + ids: [...new Set(resultFaceIds)].filter((id) => survivingFaceIds.has(id)), + }, + } +} + +function dissolveFaces( + topology: BlockTopology, + command: Extract, +): BlockCommandResult { + const faceIds = new Set(command.faceIds) + if (faceIds.size < 2) return { ok: false, error: 'Select at least two faces to dissolve' } + const missing = [...faceIds].find((id) => !topology.faces.some((face) => face.id === id)) + if (missing) return { ok: false, error: `Face not found: ${missing}` } + const internalEdgeIds = topology.edges + .filter((edge) => { + const incidentSelectedFaces = topology.faces.filter( + (face) => faceIds.has(face.id) && faceContainsEdge(face, ...edge.vertexIds), + ) + return incidentSelectedFaces.length === 2 + }) + .map((edge) => edge.id) + if (internalEdgeIds.length === 0) { + return { ok: false, error: 'Selected faces do not share a dissolvable boundary' } + } + const result = dissolveEdges(topology, { type: 'dissolve-edges', edgeIds: internalEdgeIds }) + if (!result.ok) return result + const survivingSelectedIds = result.topology.faces + .filter((face) => faceIds.has(face.id)) + .map((face) => face.id) + return { + ok: true, + topology: result.topology, + selection: { mode: 'face', ids: survivingSelectedIds }, + } +} + export function applyBlockCommand( topology: BlockTopology, command: BlockCommand, @@ -1202,25 +1395,27 @@ export function applyBlockCommand( const issues = inspectBlockTopology(topology) if (issues.length > 0) return { ok: false, error: issues[0]!.message } switch (command.type) { - case 'extrude-face': - return extrudeFace(topology, command) + case 'extrude-faces': + return extrudeFaces(topology, command) case 'translate-components': return translateComponents(topology, command) case 'rotate-components': return rotateComponents(topology, command) case 'scale-components': return scaleComponents(topology, command) - case 'inset-face': - return insetFace(topology, command) + case 'inset-faces': + return insetFaces(topology, command) case 'delete-components': return deleteComponents(topology, command) case 'merge-vertices': return mergeVertices(topology, command) - case 'dissolve-edge': - return dissolveEdge(topology, command) + case 'dissolve-edges': + return dissolveEdges(topology, command) + case 'dissolve-faces': + return dissolveFaces(topology, command) case 'loop-cut': return loopCut(topology, command) - case 'bevel-edge': - return bevelEdge(topology, command) + case 'bevel-edges': + return bevelEdges(topology, command) } } diff --git a/packages/nodes/src/block/contextual-help.test.ts b/packages/nodes/src/block/contextual-help.test.ts index 72d9a5d323..4c2c4917bd 100644 --- a/packages/nodes/src/block/contextual-help.test.ts +++ b/packages/nodes/src/block/contextual-help.test.ts @@ -9,7 +9,6 @@ describe('block contextual help', () => { useBlockEditSession.setState({ nodeId: null, selection: createBlockSelection('face'), - activeMaterialSlotId: null, }) }) diff --git a/packages/nodes/src/block/edit-session.test.ts b/packages/nodes/src/block/edit-session.test.ts index 429894db1f..7b4c51253a 100644 --- a/packages/nodes/src/block/edit-session.test.ts +++ b/packages/nodes/src/block/edit-session.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { createBoxBlockTopology } from '@pascal-app/core' import useBlockEditSession from './edit-session' +import type { BlockLastOperation } from './last-operation' import { createBlockSelection } from './selection-model' describe('block edit session', () => { @@ -8,7 +9,7 @@ describe('block edit session', () => { useBlockEditSession.setState({ nodeId: null, selection: createBlockSelection('face'), - activeMaterialSlotId: null, + lastOperation: null, }) }) @@ -32,13 +33,11 @@ describe('block edit session', () => { const selection = createBlockSelection('face', ['f-top']) useBlockEditSession.getState().begin('block_1', selection) useBlockEditSession.getState().setSelection('block_2', createBlockSelection('vertex', ['v0'])) - useBlockEditSession.getState().setActiveMaterialSlot('block_2', 'accent') useBlockEditSession.getState().end('block_2') expect(useBlockEditSession.getState()).toMatchObject({ nodeId: 'block_1', selection, - activeMaterialSlotId: null, }) }) @@ -60,13 +59,34 @@ describe('block edit session', () => { test('ends only the owned session and resets transient selection', () => { useBlockEditSession.getState().begin('block_1', createBlockSelection('face', ['f-top'])) - useBlockEditSession.getState().setActiveMaterialSlot('block_1', 'accent') useBlockEditSession.getState().end('block_1') expect(useBlockEditSession.getState()).toMatchObject({ nodeId: null, selection: { mode: 'face', ids: [], activeId: null }, - activeMaterialSlotId: null, }) }) + + test('keeps the latest adjustable operation only for its owning block', () => { + const operation = { + nodeId: 'block_1', + label: 'Move', + baseTopology: createBoxBlockTopology(), + resultTopology: createBoxBlockTopology(), + resultSelection: { mode: 'vertex', ids: ['v0'] }, + command: { + type: 'translate-components', + selection: { mode: 'vertex', ids: ['v0'] }, + delta: [1, 0, 0], + }, + historyDepth: 1, + } as BlockLastOperation + useBlockEditSession.getState().begin('block_1', createBlockSelection('vertex', ['v0'])) + useBlockEditSession.getState().setLastOperation('block_2', operation) + expect(useBlockEditSession.getState().lastOperation).toBeNull() + useBlockEditSession.getState().setLastOperation('block_1', operation) + expect(useBlockEditSession.getState().lastOperation).toBe(operation) + useBlockEditSession.getState().end('block_1') + expect(useBlockEditSession.getState().lastOperation).toBeNull() + }) }) diff --git a/packages/nodes/src/block/edit-session.ts b/packages/nodes/src/block/edit-session.ts index b12305df4e..4327d3bec7 100644 --- a/packages/nodes/src/block/edit-session.ts +++ b/packages/nodes/src/block/edit-session.ts @@ -1,16 +1,17 @@ import type { BlockTopology } from '@pascal-app/core' import { create } from 'zustand' +import type { BlockLastOperation } from './last-operation' import { type BlockSelectionState, createBlockSelection } from './selection-model' type BlockEditSessionState = { nodeId: string | null selection: BlockSelectionState - activeMaterialSlotId: string | null + lastOperation: BlockLastOperation | null begin: (nodeId: string, selection: BlockSelectionState) => void end: (nodeId: string) => void setSelection: (nodeId: string, selection: BlockSelectionState) => void - setActiveMaterialSlot: (nodeId: string, slotId: string) => void reconcileSelection: (nodeId: string, topology: BlockTopology) => void + setLastOperation: (nodeId: string, operation: BlockLastOperation | null) => void } const emptySelection = () => createBlockSelection('face') @@ -18,18 +19,18 @@ const emptySelection = () => createBlockSelection('face') const useBlockEditSession = create((set) => ({ nodeId: null, selection: emptySelection(), - activeMaterialSlotId: null, - begin: (nodeId, selection) => set({ nodeId, selection, activeMaterialSlotId: null }), + lastOperation: null, + begin: (nodeId, selection) => set({ nodeId, selection, lastOperation: null }), end: (nodeId) => set((state) => state.nodeId === nodeId - ? { nodeId: null, selection: emptySelection(), activeMaterialSlotId: null } + ? { nodeId: null, selection: emptySelection(), lastOperation: null } : state, ), setSelection: (nodeId, selection) => set((state) => (state.nodeId === nodeId ? { selection } : state)), - setActiveMaterialSlot: (nodeId, activeMaterialSlotId) => - set((state) => (state.nodeId === nodeId ? { activeMaterialSlotId } : state)), + setLastOperation: (nodeId, lastOperation) => + set((state) => (state.nodeId === nodeId ? { lastOperation } : state)), reconcileSelection: (nodeId, topology) => set((state) => { if (state.nodeId !== nodeId) return state diff --git a/packages/nodes/src/block/geometry-snap.test.ts b/packages/nodes/src/block/geometry-snap.test.ts new file mode 100644 index 0000000000..77930733b1 --- /dev/null +++ b/packages/nodes/src/block/geometry-snap.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test' +import { type BlockTopology, createBoxBlockTopology } from '@pascal-app/core' +import { PerspectiveCamera, Vector3 } from 'three' +import { blockGeometrySnapThreshold, resolveBlockGeometrySnap } from './geometry-snap' + +describe('block geometry snapping', () => { + test('keeps the acquisition radius consistent in screen pixels as the camera moves', () => { + const camera = new PerspectiveCamera(60, 1, 0.1, 100) + camera.position.set(0, 0, 5) + camera.updateMatrixWorld() + const nearThreshold = blockGeometrySnapThreshold( + camera, + new Vector3(0, 0, 0), + 1000, + new Vector3(1, 1, 1), + ) + + camera.position.z = 10 + camera.updateMatrixWorld() + const farThreshold = blockGeometrySnapThreshold( + camera, + new Vector3(0, 0, 0), + 1000, + new Vector3(1, 1, 1), + ) + + expect(farThreshold / nearThreshold).toBeCloseTo(2) + }) + + test('snaps a selected vertex to another vertex', () => { + const snap = resolveBlockGeometrySnap( + createBoxBlockTopology(), + { mode: 'vertex', ids: ['v6'], activeId: 'v6' }, + [-1.96, 0, 0], + 'free', + 0.1, + ) + + expect(snap?.kind).toBe('vertex') + expect(snap?.targetId).toBe('v7') + expect(snap?.delta).toEqual([-2, 0, 0]) + }) + + test('respects an axis constraint while snapping selection center to an edge', () => { + const snap = resolveBlockGeometrySnap( + createBoxBlockTopology(), + { mode: 'edge', ids: ['e5'], activeId: 'e5' }, + [-1.93, 0, 0], + 'x', + 0.1, + ) + + expect(snap?.kind).toBe('edge') + expect(snap?.targetId).toBe('e7') + expect(snap?.delta[1]).toBe(0) + expect(snap?.delta[2]).toBe(0) + }) + + test('ranks nearby targets by their legal correction under an axis constraint', () => { + const topology: BlockTopology = { + vertices: [ + { id: 'source', position: [0, 0, 0] }, + { id: 'closer-in-3d', position: [0.08, 0.01, 0] }, + { id: 'closer-on-axis', position: [0.02, 0.09, 0] }, + ], + edges: [], + faces: [], + } + const snap = resolveBlockGeometrySnap( + topology, + { mode: 'vertex', ids: ['source'], activeId: 'source' }, + [0, 0, 0], + 'x', + 0.1, + ) + + expect(snap?.targetId).toBe('closer-on-axis') + expect(snap?.delta).toEqual([0.02, 0, 0]) + }) + + test('does not report geometry snap when a target requires no legal movement', () => { + const topology: BlockTopology = { + vertices: [ + { id: 'source', position: [0, 0, 0] }, + { id: 'off-axis', position: [0, 0.05, 0] }, + ], + edges: [], + faces: [], + } + + expect( + resolveBlockGeometrySnap( + topology, + { mode: 'vertex', ids: ['source'], activeId: 'source' }, + [0, 0, 0], + 'x', + 0.1, + ), + ).toBeNull() + }) + + test('snaps an active face center onto another face surface', () => { + const snap = resolveBlockGeometrySnap( + createBoxBlockTopology(), + { mode: 'face', ids: ['f-top'], activeId: 'f-top' }, + [0, -2.35, 0], + 'y', + 0.1, + ) + + expect(snap?.kind).toBe('face') + expect(snap?.targetId).toBe('f-bottom') + expect(snap?.delta).toEqual([0, -2.4, 0]) + }) + + test('returns no snap outside the acquisition threshold', () => { + expect( + resolveBlockGeometrySnap( + createBoxBlockTopology(), + { mode: 'vertex', ids: ['v6'], activeId: 'v6' }, + [-1.5, 0, 0], + 'free', + 0.1, + ), + ).toBeNull() + }) +}) diff --git a/packages/nodes/src/block/geometry-snap.ts b/packages/nodes/src/block/geometry-snap.ts new file mode 100644 index 0000000000..d287546de5 --- /dev/null +++ b/packages/nodes/src/block/geometry-snap.ts @@ -0,0 +1,178 @@ +import type { BlockTopology } from '@pascal-app/core' +import { + type Camera, + MathUtils, + OrthographicCamera, + PerspectiveCamera, + Triangle, + Vector3, +} from 'three' +import { type BlockSelection, blockSelectionVertexIds } from './commands' +import { triangulateBlockFace } from './geometry' +import type { BlockTransformConstraint } from './modal-transform' + +type Point = [number, number, number] + +export type BlockGeometrySnap = { + delta: Point + kind: 'vertex' | 'edge' | 'face' + source: Point + target: Point + targetId: string +} + +export function blockGeometrySnapThreshold( + camera: Camera, + worldPoint: Vector3, + viewportHeight: number, + worldScale: Vector3, + radiusPixels = 18, +): number { + let worldUnitsPerPixel = 0 + if (camera instanceof PerspectiveCamera) { + const cameraDepth = Math.abs(worldPoint.clone().applyMatrix4(camera.matrixWorldInverse).z) + worldUnitsPerPixel = + (2 * cameraDepth * Math.tan(MathUtils.degToRad(camera.getEffectiveFOV() * 0.5))) / + Math.max(viewportHeight, 1) + } else if (camera instanceof OrthographicCamera) { + worldUnitsPerPixel = (camera.top - camera.bottom) / Math.max(camera.zoom * viewportHeight, 1) + } + const largestWorldScale = Math.max( + Math.abs(worldScale.x), + Math.abs(worldScale.y), + Math.abs(worldScale.z), + 1e-6, + ) + return (worldUnitsPerPixel * radiusPixels) / largestWorldScale +} + +function centroid(points: readonly Point[]): Point | null { + if (points.length === 0) return null + const total = points.reduce( + (sum, point) => [sum[0] + point[0], sum[1] + point[1], sum[2] + point[2]] as Point, + [0, 0, 0] as Point, + ) + return total.map((value) => value / points.length) as Point +} + +function closestPointOnSegment(point: Point, start: Point, end: Point): Point { + const segment = new Vector3(...end).sub(new Vector3(...start)) + const lengthSquared = segment.lengthSq() + if (lengthSquared < 1e-12) return [...start] + const factor = Math.min( + 1, + Math.max(0, new Vector3(...point).sub(new Vector3(...start)).dot(segment) / lengthSquared), + ) + return new Vector3(...start).addScaledVector(segment, factor).toArray() as Point +} + +function constrainedCorrection(correction: Point, constraint: BlockTransformConstraint): Point { + if (constraint === 'free' || constraint === 'uniform') return correction + return correction.map((value, index) => { + const axis = index === 0 ? 'x' : index === 1 ? 'y' : 'z' + return constraint.includes(axis) ? value : 0 + }) as Point +} + +export function resolveBlockGeometrySnap( + topology: BlockTopology, + selection: BlockSelection & { activeId?: string | null }, + proposedDelta: Point, + constraint: BlockTransformConstraint, + threshold: number, +): BlockGeometrySnap | null { + if (!(threshold > 0)) return null + const vertexById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) + const selectedVertexIds = blockSelectionVertexIds(topology, selection) + const selectedPoints = [...selectedVertexIds] + .map((id) => vertexById.get(id)) + .filter((point): point is Point => Boolean(point)) + if (selectedPoints.length === 0) return null + + const sources: Point[] = [] + if (selection.activeId) { + if (selection.mode === 'vertex') { + const point = vertexById.get(selection.activeId) + if (point) sources.push(point) + } else if (selection.mode === 'edge') { + const edge = topology.edges.find((entry) => entry.id === selection.activeId) + const points = edge?.vertexIds + .map((id) => vertexById.get(id)) + .filter((point): point is Point => Boolean(point)) + const point = points ? centroid(points) : null + if (point) sources.push(point) + } else { + const face = topology.faces.find((entry) => entry.id === selection.activeId) + const points = face?.vertexIds + .map((id) => vertexById.get(id)) + .filter((point): point is Point => Boolean(point)) + const point = points ? centroid(points) : null + if (point) sources.push(point) + } + } + sources.push(...selectedPoints) + const selectionCenter = centroid(selectedPoints) + if (selectionCenter) sources.push(selectionCenter) + + let best: (BlockGeometrySnap & { distance: number }) | null = null + const consider = ( + source: Point, + target: Point, + kind: BlockGeometrySnap['kind'], + targetId: string, + ) => { + const movedSource = source.map((value, index) => value + proposedDelta[index]!) as Point + const correction = target.map((value, index) => value - movedSource[index]!) as Point + if (Math.hypot(...correction) > threshold) return + const allowedCorrection = constrainedCorrection(correction, constraint) + const distance = Math.hypot(...allowedCorrection) + if (distance <= 1e-8 || (best && distance >= best.distance)) return + best = { + delta: proposedDelta.map((value, index) => value + allowedCorrection[index]!) as Point, + distance, + kind, + source, + target, + targetId, + } + } + + for (const source of sources) { + const movedSource = source.map((value, index) => value + proposedDelta[index]!) as Point + for (const vertex of topology.vertices) { + if (!selectedVertexIds.has(vertex.id)) consider(source, vertex.position, 'vertex', vertex.id) + } + for (const edge of topology.edges) { + if (edge.vertexIds.some((id) => selectedVertexIds.has(id))) continue + const start = vertexById.get(edge.vertexIds[0]) + const end = vertexById.get(edge.vertexIds[1]) + if (start && end) { + consider(source, closestPointOnSegment(movedSource, start, end), 'edge', edge.id) + } + } + for (const face of topology.faces) { + if (face.vertexIds.some((id) => selectedVertexIds.has(id))) continue + const triangulated = triangulateBlockFace(topology, face) + if (!triangulated) continue + for (const points of triangulated.triangles) { + const triangle = new Triangle( + new Vector3(...points[0]), + new Vector3(...points[1]), + new Vector3(...points[2]), + ) + const target = triangle.closestPointToPoint(new Vector3(...movedSource), new Vector3()) + consider(source, target.toArray() as Point, 'face', face.id) + } + } + } + + if (!best) return null + const resolved = best as BlockGeometrySnap & { distance: number } + return { + delta: resolved.delta, + kind: resolved.kind, + source: resolved.source, + target: resolved.target, + targetId: resolved.targetId, + } +} diff --git a/packages/nodes/src/block/geometry.test.ts b/packages/nodes/src/block/geometry.test.ts index 697c0f6804..c69c110961 100644 --- a/packages/nodes/src/block/geometry.test.ts +++ b/packages/nodes/src/block/geometry.test.ts @@ -277,8 +277,8 @@ describe('buildBlockGeometry', () => { test('rebuilds the extruded topology into additional face triangles', () => { const node = BlockNode.parse({ name: 'Box' }) const result = applyBlockCommand(node.topology, { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) expect(result.ok).toBe(true) @@ -295,8 +295,8 @@ describe('buildBlockGeometry', () => { test('smooths rounded bevel bands without softening the original box corners', () => { const node = BlockNode.parse({ name: 'Box' }) const result = applyBlockCommand(node.topology, { - type: 'bevel-edge', - edgeId: 'e0', + type: 'bevel-edges', + edgeIds: ['e0'], width: 0.2, segments: 6, profile: 0.5, diff --git a/packages/nodes/src/block/last-operation.test.ts b/packages/nodes/src/block/last-operation.test.ts new file mode 100644 index 0000000000..3568d5a4dc --- /dev/null +++ b/packages/nodes/src/block/last-operation.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { BlockNode, createSceneApi, runAsSingleSceneHistoryStep, useScene } from '@pascal-app/core' +import { applyBlockCommand } from './commands' +import { + recordCommittedBlockOperation, + repeatCommittedBlockOperation, + replaceCommittedBlockOperation, +} from './last-operation' + +globalThis.requestAnimationFrame ??= (callback: FrameRequestCallback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} + +describe('block last operation history transaction', () => { + const node = BlockNode.parse({ name: 'Adjustable block' }) + const services = { + historyApi: { + depth: () => useScene.temporal.getState().pastStates.length, + replaceLatest: (expectedDepth: number, replace: () => boolean) => { + if (useScene.temporal.getState().pastStates.length !== expectedDepth) return false + let replaced = false + runAsSingleSceneHistoryStep(useScene, () => { + useScene.temporal.getState().undo() + replaced = replace() + if (!replaced) useScene.temporal.getState().redo() + }) + return replaced + }, + }, + readOnly: false, + sceneApi: createSceneApi(useScene), + } + + beforeEach(() => { + useScene.setState({ nodes: { [node.id]: node }, dirtyNodes: new Set(), readOnly: false }) + useScene.temporal.getState().clear() + }) + + afterEach(() => { + useScene.setState({ nodes: {}, dirtyNodes: new Set(), readOnly: false }) + useScene.temporal.getState().clear() + }) + + test('replaces the committed result while preserving one undo step', () => { + const firstCommand = { type: 'extrude-faces', faceIds: ['f-top'], distance: 0.25 } as const + const first = applyBlockCommand(node.topology, firstCommand) + expect(first.ok).toBe(true) + if (!first.ok) return + useScene.getState().updateNode(node.id, { topology: first.topology }) + const record = recordCommittedBlockOperation( + services, + node.id, + 'Extrude', + node.topology, + firstCommand, + first, + ) + + const adjusted = replaceCommittedBlockOperation(services, record, { + ...firstCommand, + distance: 0.5, + }) + + expect(adjusted.ok).toBe(true) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + const current = useScene.getState().nodes[node.id] + expect(current?.type).toBe('block') + if (current?.type !== 'block') return + const top = current.topology.faces.find((face) => face.id === 'f-top') + expect( + top?.vertexIds.map( + (id) => current.topology.vertices.find((vertex) => vertex.id === id)!.position[1], + ), + ).toEqual([2.9, 2.9, 2.9, 2.9]) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[node.id]).toEqual(node) + }) + + test('repeats the operation from its latest result as a new undo step', () => { + const command = { type: 'extrude-faces', faceIds: ['f-top'], distance: 0.25 } as const + const first = applyBlockCommand(node.topology, command) + expect(first.ok).toBe(true) + if (!first.ok) return + useScene.getState().updateNode(node.id, { topology: first.topology }) + const record = recordCommittedBlockOperation( + services, + node.id, + 'Extrude', + node.topology, + command, + first, + ) + + const repeated = repeatCommittedBlockOperation(services, record, { + mode: 'face', + ids: ['f-top'], + activeId: 'f-top', + }) + + expect(repeated.ok).toBe(true) + expect(useScene.temporal.getState().pastStates).toHaveLength(2) + const current = useScene.getState().nodes[node.id] + expect(current?.type).toBe('block') + if (current?.type !== 'block') return + const top = current.topology.faces.find((face) => face.id === 'f-top') + expect( + top?.vertexIds.map( + (id) => current.topology.vertices.find((vertex) => vertex.id === id)!.position[1], + ), + ).toEqual([2.9, 2.9, 2.9, 2.9]) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[node.id]).toMatchObject({ topology: first.topology }) + }) +}) diff --git a/packages/nodes/src/block/last-operation.ts b/packages/nodes/src/block/last-operation.ts new file mode 100644 index 0000000000..30b296d8cf --- /dev/null +++ b/packages/nodes/src/block/last-operation.ts @@ -0,0 +1,175 @@ +import type { AnyNodeId, BlockNode, BlockTopology, SceneApi } from '@pascal-app/core' +import type { SelectionAffordanceHistoryApi } from '@pascal-app/editor' +import { + applyBlockCommand, + type BlockCommand, + type BlockCommandResult, + type BlockSelection, + blockSelectionVertexIds, +} from './commands' + +type SuccessfulBlockCommandResult = Extract + +export type BlockOperationServices = { + historyApi: SelectionAffordanceHistoryApi + readOnly: boolean + sceneApi: Pick +} + +export type BlockLastOperation = { + baseTopology: BlockTopology + command: BlockCommand + historyDepth: number + label: string + nodeId: AnyNodeId + resultSelection: BlockSelection + resultTopology: BlockTopology +} + +export type BlockLastOperationReplacement = + | { ok: true; operation: BlockLastOperation } + | { ok: false; error: string } + +type RepeatSelection = BlockSelection & { activeId: string | null } +type Point = [number, number, number] + +function sameTopology(left: BlockTopology, right: BlockTopology): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +function selectionCentroid(topology: BlockTopology, selection: BlockSelection): Point | null { + const selectedIds = blockSelectionVertexIds(topology, selection) + const points = topology.vertices.filter((vertex) => selectedIds.has(vertex.id)) + if (points.length === 0) return null + const total = points.reduce( + (sum, vertex) => vertex.position.map((value, index) => sum[index]! + value) as Point, + [0, 0, 0] as Point, + ) + return total.map((value) => value / points.length) as Point +} + +function commandForRepeat( + command: BlockCommand, + topology: BlockTopology, + selection: RepeatSelection, +): BlockCommand | null { + const activeId = selection.activeId ?? selection.ids.at(-1) + switch (command.type) { + case 'translate-components': + return selection.ids.length > 0 ? { ...command, selection } : null + case 'rotate-components': { + const pivot = selectionCentroid(topology, selection) + return pivot ? { ...command, selection, pivot } : null + } + case 'scale-components': { + const pivot = selectionCentroid(topology, selection) + return pivot ? { ...command, selection, pivot } : null + } + case 'extrude-faces': + return selection.mode === 'face' && selection.ids.length > 0 + ? { ...command, faceIds: selection.ids } + : null + case 'inset-faces': + return selection.mode === 'face' && selection.ids.length > 0 + ? { ...command, faceIds: selection.ids } + : null + case 'bevel-edges': + return selection.mode === 'edge' && selection.ids.length > 0 + ? { ...command, edgeIds: selection.ids } + : null + case 'loop-cut': + return selection.mode === 'edge' && activeId ? { ...command, edgeId: activeId } : null + default: + return null + } +} + +export function recordCommittedBlockOperation( + services: BlockOperationServices, + nodeId: AnyNodeId, + label: string, + baseTopology: BlockTopology, + command: BlockCommand, + result: SuccessfulBlockCommandResult, +): BlockLastOperation { + return { + baseTopology, + command, + historyDepth: services.historyApi.depth(), + label, + nodeId, + resultSelection: result.selection, + resultTopology: result.topology, + } +} + +export function replaceCommittedBlockOperation( + services: BlockOperationServices, + operation: BlockLastOperation, + command: BlockCommand, +): BlockLastOperationReplacement { + if (services.readOnly) return { ok: false, error: 'Scene is read-only' } + const current = services.sceneApi.get(operation.nodeId) + if (current?.type !== 'block' || !sameTopology(current.topology, operation.resultTopology)) { + return { ok: false, error: 'The last operation is no longer the latest scene change' } + } + if (services.historyApi.depth() !== operation.historyDepth) { + return { ok: false, error: 'Scene history changed after the last operation' } + } + + const result = applyBlockCommand(operation.baseTopology, command) + if (!result.ok) return result + + const restored = services.historyApi.replaceLatest(operation.historyDepth, () => { + const baseline = services.sceneApi.get(operation.nodeId) + if (baseline?.type !== 'block' || !sameTopology(baseline.topology, operation.baseTopology)) { + return false + } + services.sceneApi.update(operation.nodeId, { topology: result.topology }) + return true + }) + if (!restored) return { ok: false, error: 'Could not restore the operation baseline' } + + return { + ok: true, + operation: recordCommittedBlockOperation( + services, + operation.nodeId, + operation.label, + operation.baseTopology, + command, + result, + ), + } +} + +export function repeatCommittedBlockOperation( + services: BlockOperationServices, + operation: BlockLastOperation, + selection: RepeatSelection, +): BlockLastOperationReplacement { + if (services.readOnly) return { ok: false, error: 'Scene is read-only' } + const current = services.sceneApi.get(operation.nodeId) + if (current?.type !== 'block' || !sameTopology(current.topology, operation.resultTopology)) { + return { ok: false, error: 'The last operation is no longer the latest scene change' } + } + if (services.historyApi.depth() !== operation.historyDepth) { + return { ok: false, error: 'Scene history changed after the last operation' } + } + const command = commandForRepeat(operation.command, current.topology, selection) + if (!command) return { ok: false, error: 'The current selection cannot repeat this operation' } + const result = applyBlockCommand(current.topology, command) + if (!result.ok) return result + services.sceneApi.update(operation.nodeId, { topology: result.topology }) + return { + ok: true, + operation: recordCommittedBlockOperation( + services, + operation.nodeId, + operation.label, + current.topology, + command, + result, + ), + } +} diff --git a/packages/nodes/src/block/material-slots.test.ts b/packages/nodes/src/block/material-slots.test.ts index 75d702aa2c..a4f4627ff9 100644 --- a/packages/nodes/src/block/material-slots.test.ts +++ b/packages/nodes/src/block/material-slots.test.ts @@ -4,33 +4,15 @@ import { assignBlockMaterial, blockMaterialSelection, blockMaterialSlotIds, - collectReusableBlockMaterialRefs, + createAssignedBlockMaterialSlot, createBlockMaterialSlot, removeBlockMaterialSlot, renameBlockMaterialSlot, - selectBlockFacesByMaterialSlot, setBlockMaterialSlot, + unpaintedBlockMaterialSlotIds, } from './material-slots' describe('block material slots', () => { - test('offers catalog refs already used in scene slots when no scene materials exist', () => { - expect( - collectReusableBlockMaterialRefs([{ slots: { body: 'library:metal-steel' } }], []), - ).toEqual(['library:metal-steel']) - }) - - test('deduplicates used refs and includes unused reusable scene materials', () => { - expect( - collectReusableBlockMaterialRefs( - [ - { slots: { body: 'scene:mat_shared', accent: 'library:oak' } }, - { slots: { trim: 'scene:mat_shared', invalid: 'not-a-material-ref' } }, - ], - ['mat_shared', 'mat_unused'], - ), - ).toEqual(['scene:mat_shared', 'library:oak', 'scene:mat_unused']) - }) - test('lists body, persisted, and face-referenced slots in stable order', () => { const topology = createBoxBlockTopology() topology.faces[0] = { ...topology.faces[0], materialSlot: 'orphaned' } @@ -56,6 +38,46 @@ describe('block material slots', () => { ).toEqual({ body: 'Body', 'slot-1': 'Trim' }) }) + test('creates a slot and assigns it to the selected faces in one operation', () => { + const topology = createBoxBlockTopology() + const result = createAssignedBlockMaterialSlot( + topology, + undefined, + { body: 'Body' }, + ['f-top', 'f-front'], + 'scene:block-accent', + ) + + expect(result.changed).toBe(true) + expect(result.slotId).toBe('slot-1') + expect(result.slotNames).toEqual({ body: 'Body', 'slot-1': 'Slot 1' }) + expect(result.slots).toEqual({ 'slot-1': 'scene:block-accent' }) + expect(result.topology.faces.map((face) => face.materialSlot)).toEqual([ + 'body', + 'slot-1', + 'slot-1', + 'body', + 'body', + 'body', + ]) + }) + + test('does not create an empty slot when no faces are selected', () => { + const topology = createBoxBlockTopology() + const slotNames = { body: 'Body' } + const result = createAssignedBlockMaterialSlot( + topology, + undefined, + slotNames, + [], + 'scene:block-accent', + ) + + expect(result.changed).toBe(false) + expect(result.topology).toBe(topology) + expect(result.slotNames).toBe(slotNames) + }) + test('updates a slot material without changing face assignments', () => { const slots = { body: 'library:wood' } expect(setBlockMaterialSlot(slots, 'body', 'library:metal-steel')).toEqual({ @@ -68,6 +90,19 @@ describe('block material slots', () => { }) }) + test('identifies unpainted non-body slots for the edit-mode tint', () => { + const topology = createBoxBlockTopology() + topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } + + expect( + unpaintedBlockMaterialSlotIds( + topology, + { body: 'library:wood', painted: 'library:metal-steel' }, + { accent: 'Accent', painted: 'Painted' }, + ), + ).toEqual(['accent']) + }) + test('reports single and mixed face assignments using the active face', () => { const topology = createBoxBlockTopology() topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } @@ -87,26 +122,6 @@ describe('block material slots', () => { }) }) - test('selects and deselects every face assigned to a slot without replacing other selection', () => { - const topology = createBoxBlockTopology() - topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } - topology.faces[2] = { ...topology.faces[2], materialSlot: 'accent' } - - expect(selectBlockFacesByMaterialSlot(topology, ['f-bottom'], 'accent', 'select')).toEqual([ - 'f-bottom', - 'f-top', - 'f-front', - ]) - expect( - selectBlockFacesByMaterialSlot( - topology, - ['f-bottom', 'f-top', 'f-front'], - 'accent', - 'deselect', - ), - ).toEqual(['f-bottom']) - }) - test('removes a material slot and remaps all of its faces to the first slot', () => { const topology = createBoxBlockTopology() topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } diff --git a/packages/nodes/src/block/material-slots.ts b/packages/nodes/src/block/material-slots.ts index 23878bd3de..761acfe685 100644 --- a/packages/nodes/src/block/material-slots.ts +++ b/packages/nodes/src/block/material-slots.ts @@ -1,9 +1,4 @@ -import { - type BlockTopology, - type MaterialRef, - parseMaterialRef, - toSceneMaterialRef, -} from '@pascal-app/core' +import type { BlockTopology, MaterialRef } from '@pascal-app/core' export const BLOCK_BODY_SLOT_ID = 'body' @@ -42,29 +37,19 @@ export type BlockMaterialSlotCreationResult = { slotNames: Record } -function materialSlotsFromNode(node: unknown): Record | null { - if (!node || typeof node !== 'object' || !('slots' in node)) return null - const slots = (node as { slots?: unknown }).slots - return slots && typeof slots === 'object' && !Array.isArray(slots) - ? (slots as Record) - : null -} - -export function collectReusableBlockMaterialRefs( - nodes: readonly unknown[], - sceneMaterialIds: readonly string[], -): MaterialRef[] { - const refs = new Set() - for (const node of nodes) { - const slots = materialSlotsFromNode(node) - if (!slots) continue - for (const value of Object.values(slots)) { - if (typeof value === 'string' && parseMaterialRef(value)) refs.add(value) +export type BlockAssignedMaterialSlotCreationResult = + | (BlockMaterialSlotCreationResult & { + topology: BlockTopology + slots: BlockMaterialSlots + changed: true + }) + | { + topology: BlockTopology + slots: BlockMaterialSlots + slotId: null + slotNames: BlockMaterialSlotNames + changed: false } - } - for (const id of sceneMaterialIds) refs.add(toSceneMaterialRef(id)) - return [...refs] -} export function blockMaterialSlotIds( topology: BlockTopology, @@ -78,6 +63,16 @@ export function blockMaterialSlotIds( return [...slotIds] } +export function unpaintedBlockMaterialSlotIds( + topology: BlockTopology, + slots: BlockMaterialSlots, + slotNames?: BlockMaterialSlotNames, +): string[] { + return blockMaterialSlotIds(topology, slots, slotNames).filter( + (slotId) => slotId !== BLOCK_BODY_SLOT_ID && !slots?.[slotId], + ) +} + export function createBlockMaterialSlot( topology: BlockTopology, slots: BlockMaterialSlots, @@ -93,6 +88,35 @@ export function createBlockMaterialSlot( } } +export function createAssignedBlockMaterialSlot( + topology: BlockTopology, + slots: BlockMaterialSlots, + slotNames: BlockMaterialSlotNames, + selectedFaceIds: readonly string[], + materialRef: MaterialRef, +): BlockAssignedMaterialSlotCreationResult { + const selected = new Set(selectedFaceIds) + if (!topology.faces.some((face) => selected.has(face.id))) { + return { topology, slots, slotId: null, slotNames, changed: false } + } + const created = createBlockMaterialSlot(topology, slots, slotNames) + const assigned = assignBlockMaterial( + topology, + slots, + selectedFaceIds, + { kind: 'slot', slotId: created.slotId }, + created.slotNames, + ) + const bound = setBlockMaterialSlot(assigned.slots, created.slotId, materialRef) + return { + topology: assigned.topology, + slots: bound.slots, + slotId: created.slotId, + slotNames: created.slotNames, + changed: true, + } +} + export function renameBlockMaterialSlot( topology: BlockTopology, slots: BlockMaterialSlots, @@ -192,28 +216,6 @@ export function removeBlockMaterialSlot( } } -export function selectBlockFacesByMaterialSlot( - topology: BlockTopology, - selectedFaceIds: readonly string[], - slotId: string, - operation: 'select' | 'deselect', -): string[] { - const matching = new Set( - topology.faces.filter((face) => face.materialSlot === slotId).map((face) => face.id), - ) - if (operation === 'deselect') { - return selectedFaceIds.filter((faceId) => !matching.has(faceId)) - } - - const selected = new Set(selectedFaceIds) - return [ - ...selectedFaceIds, - ...topology.faces - .filter((face) => matching.has(face.id) && !selected.has(face.id)) - .map((face) => face.id), - ] -} - export function assignBlockMaterial( topology: BlockTopology, slots: BlockMaterialSlots, diff --git a/packages/nodes/src/block/modal-face-operation.test.ts b/packages/nodes/src/block/modal-face-operation.test.ts new file mode 100644 index 0000000000..b5847434e9 --- /dev/null +++ b/packages/nodes/src/block/modal-face-operation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' +import { + blockFaceOperationCommand, + blockFaceOperationValueFromPointer, + blockModalFaceOperationStatus, +} from './modal-face-operation' + +describe('block modal face operation', () => { + test('maps pointer travel to signed extrusion distance and bounded inset amount', () => { + expect(blockFaceOperationValueFromPointer('extrude', 60, -30, 2)).toBeCloseTo(0.9) + expect(blockFaceOperationValueFromPointer('extrude', -60, 30, 2)).toBeCloseTo(-0.9) + expect(blockFaceOperationValueFromPointer('inset', 60, -30, 2)).toBeCloseTo(0.45) + expect(blockFaceOperationValueFromPointer('inset', 500, -500, 2)).toBe(0.95) + }) + + test('creates a pure topology command from the modal value', () => { + expect(blockFaceOperationCommand('extrude', ['f-top'], -0.4)).toEqual({ + type: 'extrude-faces', + faceIds: ['f-top'], + distance: -0.4, + }) + expect(blockFaceOperationCommand('inset', ['f-top'], 0.2)).toEqual({ + type: 'inset-faces', + faceIds: ['f-top'], + amount: 0.2, + depth: 0, + }) + }) + + test('reports operation value and modal controls', () => { + expect(blockModalFaceOperationStatus('extrude', '0.35', 'grid')).toBe( + 'Extrude · 0.35 m · Grid snap · type value · click applies · Esc cancels', + ) + expect(blockModalFaceOperationStatus('inset', '0.2')).toBe( + 'Inset · 0.2 ratio · Free · type value · click applies · Esc cancels', + ) + }) +}) diff --git a/packages/nodes/src/block/modal-face-operation.ts b/packages/nodes/src/block/modal-face-operation.ts new file mode 100644 index 0000000000..6a52f9e754 --- /dev/null +++ b/packages/nodes/src/block/modal-face-operation.ts @@ -0,0 +1,35 @@ +import type { BlockCommand } from './commands' +import { type BlockModalFeedbackMode, blockModalFeedbackLabel } from './modal-transform' + +export type BlockModalFaceOperation = 'extrude' | 'inset' + +export function blockFaceOperationValueFromPointer( + operation: BlockModalFaceOperation, + deltaX: number, + deltaY: number, + topologyExtent: number, +): number { + const pointerTravel = deltaX - deltaY + if (operation === 'extrude') return pointerTravel * 0.01 + return Math.min(0.95, Math.max(0, pointerTravel / (Math.max(0.5, topologyExtent) * 100))) +} + +export function blockFaceOperationCommand( + operation: BlockModalFaceOperation, + faceIds: string[], + value: number, +): BlockCommand { + return operation === 'extrude' + ? { type: 'extrude-faces', faceIds, distance: value } + : { type: 'inset-faces', faceIds, amount: value, depth: 0 } +} + +export function blockModalFaceOperationStatus( + operation: BlockModalFaceOperation, + value: string, + feedbackMode: BlockModalFeedbackMode = 'free', +): string { + const label = operation === 'extrude' ? 'Extrude' : 'Inset' + const unit = operation === 'extrude' ? 'm' : 'ratio' + return `${label} · ${value} ${unit} · ${blockModalFeedbackLabel(feedbackMode)} · type value · click applies · Esc cancels` +} diff --git a/packages/nodes/src/block/modal-session.ts b/packages/nodes/src/block/modal-session.ts new file mode 100644 index 0000000000..ae0ca21641 --- /dev/null +++ b/packages/nodes/src/block/modal-session.ts @@ -0,0 +1,58 @@ +import { useViewer } from '@pascal-app/viewer' +import type { MutableRefObject } from 'react' + +type FinishModal = (commit: boolean) => void + +export type BlockModalSessionOptions = { + cancelRef: MutableRefObject<(() => void) | null> + cursor: string + onFinish: (commit: boolean) => void + onKeyDown?: (event: KeyboardEvent, finish: FinishModal) => void + onPointerDown?: (event: PointerEvent, finish: FinishModal) => void + onPointerMove?: (event: PointerEvent) => void +} + +export function beginBlockModalSession({ + cancelRef, + cursor, + onFinish, + onKeyDown, + onPointerDown, + onPointerMove, +}: BlockModalSessionOptions): FinishModal { + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let finished = false + + const onContextMenu = (event: Event) => { + event.preventDefault() + event.stopImmediatePropagation() + } + const onCancel = () => finish(false) + const pointerDown = (event: PointerEvent) => onPointerDown?.(event, finish) + const keyDown = (event: KeyboardEvent) => onKeyDown?.(event, finish) + + function finish(commit: boolean) { + if (finished) return + finished = true + if (onPointerMove) window.removeEventListener('pointermove', onPointerMove, true) + if (onPointerDown) window.removeEventListener('pointerdown', pointerDown, true) + if (onKeyDown) window.removeEventListener('keydown', keyDown, true) + window.removeEventListener('contextmenu', onContextMenu, true) + window.removeEventListener('blur', onCancel) + cancelRef.current = null + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + onFinish(commit) + } + + useViewer.getState().setInputDragging(true) + document.body.style.cursor = cursor + cancelRef.current = onCancel + if (onPointerMove) window.addEventListener('pointermove', onPointerMove, true) + if (onPointerDown) window.addEventListener('pointerdown', pointerDown, true) + if (onKeyDown) window.addEventListener('keydown', keyDown, true) + window.addEventListener('contextmenu', onContextMenu, true) + window.addEventListener('blur', onCancel, { once: true }) + return finish +} diff --git a/packages/nodes/src/block/modal-transform.test.ts b/packages/nodes/src/block/modal-transform.test.ts new file mode 100644 index 0000000000..d0b39fd707 --- /dev/null +++ b/packages/nodes/src/block/modal-transform.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test' +import { + blockAccumulatePrecisionPointer, + blockAxisDelta, + blockAxisVisualState, + blockConstrainTranslationDelta, + blockModalTransformStatus, + blockNumericDeltaForConstraint, + blockPlaneVisualState, + blockPrecisionSnapStep, + blockRotationPointerAngle, + blockScaleFactorsForConstraint, + blockTransformAxisFromKey, + blockTransformConstraintFromKey, + blockTransformDisplayValue, + blockTransformNumericInputFromKey, + blockTransformNumericValue, +} from './modal-transform' + +describe('block modal transform', () => { + test('accumulates held-Shift pointer movement at one tenth speed without jumping', () => { + expect( + blockAccumulatePrecisionPointer( + { x: 120, y: 100 }, + { x: 120, y: 100 }, + { x: 140, y: 80 }, + true, + ), + ).toEqual({ x: 122, y: 98 }) + expect( + blockAccumulatePrecisionPointer( + { x: 122, y: 98 }, + { x: 140, y: 80 }, + { x: 150, y: 90 }, + false, + ), + ).toEqual({ x: 132, y: 108 }) + }) + + test('uses finer grid and angle increments during precision movement', () => { + expect(blockPrecisionSnapStep(0.5, false)).toBe(0.5) + expect(blockPrecisionSnapStep(0.5, true)).toBe(0.05) + expect(blockPrecisionSnapStep(15, true)).toBe(1.5) + }) + + test('recognizes case-insensitive transform-axis shortcuts', () => { + expect(blockTransformAxisFromKey('X')).toBe('x') + expect(blockTransformAxisFromKey('y')).toBe('y') + expect(blockTransformAxisFromKey('G')).toBeNull() + }) + + test('constrains movement to one local axis', () => { + expect(blockAxisDelta('x', 1.25)).toEqual([1.25, 0, 0]) + expect(blockAxisDelta('y', -0.5)).toEqual([0, -0.5, 0]) + expect(blockAxisDelta('z', 2)).toEqual([0, 0, 2]) + }) + + test('keeps only the locked operation axis colorful', () => { + const active = { operation: 'translate', constraint: 'y' } as const + expect(blockAxisVisualState(active, 'translate', 'y')).toBe('active') + expect(blockAxisVisualState(active, 'translate', 'x')).toBe('faded') + expect(blockAxisVisualState(active, 'rotate', 'y')).toBe('faded') + }) + + test('maps shifted axis shortcuts to the plane that excludes that axis', () => { + expect(blockTransformConstraintFromKey('X', true)).toBe('yz') + expect(blockTransformConstraintFromKey('y', true)).toBe('xz') + expect(blockTransformConstraintFromKey('Z', true)).toBe('xy') + expect(blockTransformConstraintFromKey('x', false)).toBe('x') + }) + + test('keeps accumulated movement when it is projected onto a plane lock', () => { + expect(blockConstrainTranslationDelta([0.6, 0.4, 0.2], 'xy')).toEqual([0.6, 0.4, 0]) + expect(blockConstrainTranslationDelta([0.6, 0.4, 0.2], 'yz')).toEqual([0, 0.4, 0.2]) + }) + + test('keeps the constrained plane axes and plane handle colorful', () => { + const active = { operation: 'translate', constraint: 'xz' } as const + expect(blockAxisVisualState(active, 'translate', 'x')).toBe('active') + expect(blockAxisVisualState(active, 'translate', 'z')).toBe('active') + expect(blockAxisVisualState(active, 'translate', 'y')).toBe('faded') + expect(blockPlaneVisualState(active, 'xz')).toBe('active') + expect(blockPlaneVisualState(active, 'xy')).toBe('faded') + }) + + test('keeps typed movement inside the active plane', () => { + expect(blockNumericDeltaForConstraint('xz', [3, 10, 4], 5)).toEqual([3, 0, 4]) + expect(blockNumericDeltaForConstraint('y', [3, 10, 4], -2)).toEqual([0, -2, 0]) + expect(blockNumericDeltaForConstraint('free', [0, 0, 0], 1.5)).toEqual([1.5, 0, 0]) + }) + + test('scales only the axes included by the active constraint', () => { + expect(blockScaleFactorsForConstraint('yz', 2)).toEqual([1, 2, 2]) + expect(blockScaleFactorsForConstraint('x', 0.5)).toEqual([0.5, 1, 1]) + expect(blockScaleFactorsForConstraint('uniform', 1.25)).toEqual([1.25, 1.25, 1.25]) + }) + + test('describes the current operation and constraint', () => { + expect(blockModalTransformStatus({ operation: 'rotate', constraint: 'z' })).toBe( + 'Rotate · Z axis · Free · X/Y/Z constrains · click applies · Esc cancels', + ) + }) + + test('formats live values in the operation user-facing unit', () => { + expect(blockTransformDisplayValue('translate', 1.23456)).toBe('1.235') + expect(blockTransformDisplayValue('rotate', Math.PI / 2)).toBe('90') + expect(blockTransformDisplayValue('scale', 1.25)).toBe('1.25') + }) + + test('rotates from horizontal movement when the gesture starts on the pivot', () => { + expect( + blockRotationPointerAngle({ x: 100, y: 100 }, { x: 100, y: 100 }, { x: 120, y: 100 }), + ).not.toBe(0) + }) + + test('builds signed decimal input and supports correction', () => { + let input = '' + for (const key of ['2', '.', '5']) { + input = blockTransformNumericInputFromKey(input, key)! + } + expect(input).toBe('2.5') + expect(blockTransformNumericInputFromKey(input, '-')).toBe('-2.5') + expect(blockTransformNumericInputFromKey('-2.5', 'Backspace')).toBe('-2.') + expect(blockTransformNumericInputFromKey('2.5', '.')).toBe('2.5') + expect(blockTransformNumericInputFromKey('2.5', 'x')).toBeNull() + }) + + test('interprets typed distance, angle, and scale values in their user-facing units', () => { + expect(blockTransformNumericValue('2.5', 'translate')).toBe(2.5) + expect(blockTransformNumericValue('-45', 'rotate')).toBeCloseTo(-Math.PI / 4) + expect(blockTransformNumericValue('1.25', 'scale')).toBe(1.25) + expect(blockTransformNumericValue('-', 'translate')).toBeNull() + }) + + test('includes the typed value in modal feedback', () => { + expect( + blockModalTransformStatus({ operation: 'translate', constraint: 'z' }, '-1.25', 'exact'), + ).toBe('Move · Z axis · -1.25 m · Exact · X/Y/Z constrains · click applies · Esc cancels') + expect(blockModalTransformStatus({ operation: 'rotate', constraint: 'y' }, '45', 'angle')).toBe( + 'Rotate · Y axis · 45° · Angle snap · X/Y/Z constrains · click applies · Esc cancels', + ) + expect(blockModalTransformStatus({ operation: 'translate', constraint: 'xz' })).toBe( + 'Move · XZ plane · Free · X/Y/Z constrains · click applies · Esc cancels', + ) + }) +}) diff --git a/packages/nodes/src/block/modal-transform.ts b/packages/nodes/src/block/modal-transform.ts new file mode 100644 index 0000000000..27125f656c --- /dev/null +++ b/packages/nodes/src/block/modal-transform.ts @@ -0,0 +1,218 @@ +export type BlockTransformAxis = 'x' | 'y' | 'z' +export type BlockTransformPlane = 'xy' | 'xz' | 'yz' +export type BlockTransformOperation = 'translate' | 'rotate' | 'scale' +export type BlockTransformConstraint = BlockTransformAxis | BlockTransformPlane | 'free' | 'uniform' +export type BlockModalFeedbackMode = 'free' | 'grid' | 'angle' | 'exact' | 'geometry' | 'precision' + +export type BlockActiveTransform = { + operation: BlockTransformOperation + constraint: BlockTransformConstraint +} + +export type BlockAxisVisualState = 'normal' | 'active' | 'faded' + +export type BlockScreenPoint = { x: number; y: number } + +export function blockAccumulatePrecisionPointer( + effective: BlockScreenPoint, + previousRaw: BlockScreenPoint, + currentRaw: BlockScreenPoint, + precision: boolean, +): BlockScreenPoint { + const factor = precision ? 0.1 : 1 + return { + x: effective.x + (currentRaw.x - previousRaw.x) * factor, + y: effective.y + (currentRaw.y - previousRaw.y) * factor, + } +} + +export function blockPrecisionSnapStep(step: number, precision: boolean): number { + return precision ? step * 0.1 : step +} + +export function blockRotationPointerAngle( + pivot: BlockScreenPoint, + start: BlockScreenPoint, + current: BlockScreenPoint, +): number { + const startDistanceSquared = (start.x - pivot.x) ** 2 + (start.y - pivot.y) ** 2 + if (startDistanceSquared < 64) { + return (current.x - start.x - (current.y - start.y)) * 0.01 + } + return ( + Math.atan2(current.y - pivot.y, current.x - pivot.x) - + Math.atan2(start.y - pivot.y, start.x - pivot.x) + ) +} + +export function blockTransformAxisFromKey(key: string): BlockTransformAxis | null { + const normalized = key.toLowerCase() + return normalized === 'x' || normalized === 'y' || normalized === 'z' ? normalized : null +} + +export function blockTransformConstraintFromKey( + key: string, + planeLock: boolean, +): BlockTransformAxis | BlockTransformPlane | null { + const axis = blockTransformAxisFromKey(key) + if (!axis || !planeLock) return axis + return axis === 'x' ? 'yz' : axis === 'y' ? 'xz' : 'xy' +} + +export function blockTransformNumericInputFromKey(current: string, key: string): string | null { + if (/^\d$/.test(key)) return `${current}${key}` + if (key === '.') { + if (current.includes('.')) return current + if (current === '') return '0.' + if (current === '-') return '-0.' + return `${current}.` + } + if (key === '-') return current.startsWith('-') ? current.slice(1) : `-${current}` + if (key === 'Backspace') return current.slice(0, -1) + return null +} + +export function blockTransformNumericValue( + input: string, + operation: BlockTransformOperation, +): number | null { + if (input === '' || input === '-' || input === '.' || input === '-.') return null + const value = Number(input) + if (!Number.isFinite(value)) return null + return operation === 'rotate' ? (value * Math.PI) / 180 : value +} + +export function blockTransformDisplayValue( + operation: BlockTransformOperation, + value: number, +): string { + const displayed = operation === 'rotate' ? (value * 180) / Math.PI : value + return String(Math.round(displayed * 1000) / 1000) +} + +export function blockModalFeedbackLabel(mode: BlockModalFeedbackMode): string { + return mode === 'grid' + ? 'Grid snap' + : mode === 'angle' + ? 'Angle snap' + : mode === 'exact' + ? 'Exact' + : mode === 'geometry' + ? 'Geometry snap' + : mode === 'precision' + ? 'Precision' + : 'Free' +} + +export function blockAxisDelta( + axis: BlockTransformAxis, + distance: number, +): [number, number, number] { + return [axis === 'x' ? distance : 0, axis === 'y' ? distance : 0, axis === 'z' ? distance : 0] +} + +export function blockConstrainTranslationDelta( + delta: [number, number, number], + constraint: BlockTransformConstraint, +): [number, number, number] { + if (constraint === 'free' || constraint === 'uniform') return delta + return delta.map((value, index) => { + const axis = index === 0 ? 'x' : index === 1 ? 'y' : 'z' + return constraint.includes(axis) ? value : 0 + }) as [number, number, number] +} + +export function blockNumericDeltaForConstraint( + constraint: BlockTransformConstraint, + pointerDelta: [number, number, number], + distance: number, +): [number, number, number] { + if (constraint === 'x' || constraint === 'y' || constraint === 'z') { + return blockAxisDelta(constraint, distance) + } + if (constraint === 'xy' || constraint === 'xz' || constraint === 'yz') { + const delta: [number, number, number] = [ + constraint.includes('x') ? pointerDelta[0] : 0, + constraint.includes('y') ? pointerDelta[1] : 0, + constraint.includes('z') ? pointerDelta[2] : 0, + ] + const length = Math.hypot(...delta) + if (length > 1e-8) return delta.map((value) => (value / length) * distance) as typeof delta + return blockAxisDelta(constraint[0] as BlockTransformAxis, distance) + } + return blockAxisDelta('x', distance) +} + +export function blockScaleFactorsForConstraint( + constraint: BlockTransformConstraint, + factor: number, +): [number, number, number] { + if (constraint === 'uniform' || constraint === 'free') return [factor, factor, factor] + return [ + constraint.includes('x') ? factor : 1, + constraint.includes('y') ? factor : 1, + constraint.includes('z') ? factor : 1, + ] +} + +export function blockAxisVisualState( + activeTransform: BlockActiveTransform | null, + operation: BlockTransformOperation, + axis: BlockTransformAxis, +): BlockAxisVisualState { + if (!activeTransform) return 'normal' + if (activeTransform.operation !== operation) return 'faded' + if (activeTransform.constraint === 'free' || activeTransform.constraint === 'uniform') { + return 'normal' + } + if (activeTransform.constraint.length === 2) { + return activeTransform.constraint.includes(axis) ? 'active' : 'faded' + } + return activeTransform.constraint === axis ? 'active' : 'faded' +} + +export function blockPlaneVisualState( + activeTransform: BlockActiveTransform | null, + plane: BlockTransformPlane, +): BlockAxisVisualState { + if (!activeTransform) return 'normal' + if ( + activeTransform.operation !== 'translate' || + activeTransform.constraint === 'uniform' || + activeTransform.constraint === 'free' + ) { + return activeTransform.constraint === 'free' ? 'normal' : 'faded' + } + return activeTransform.constraint === plane ? 'active' : 'faded' +} + +export function blockModalTransformStatus( + activeTransform: BlockActiveTransform, + typedInput = '', + feedbackMode: BlockModalFeedbackMode = 'free', +): string { + const operation = + activeTransform.operation === 'translate' + ? 'Move' + : activeTransform.operation === 'rotate' + ? 'Rotate' + : 'Scale' + const constraint = + activeTransform.constraint === 'free' + ? 'free' + : activeTransform.constraint === 'uniform' + ? 'uniform' + : activeTransform.constraint.length === 2 + ? `${activeTransform.constraint.toUpperCase()} plane` + : `${activeTransform.constraint.toUpperCase()} axis` + const typedValue = typedInput + ? ` · ${typedInput}${ + activeTransform.operation === 'translate' + ? ' m' + : activeTransform.operation === 'rotate' + ? '°' + : '×' + }` + : '' + return `${operation} · ${constraint}${typedValue} · ${blockModalFeedbackLabel(feedbackMode)} · X/Y/Z constrains · click applies · Esc cancels` +} diff --git a/packages/nodes/src/block/panel.tsx b/packages/nodes/src/block/panel.tsx index fc0f7a4f1c..2f9438f341 100644 --- a/packages/nodes/src/block/panel.tsx +++ b/packages/nodes/src/block/panel.tsx @@ -1,10 +1,13 @@ 'use client' import { + type AnyNode, type AnyNodeId, type BlockNode, getCatalogMaterialById, + type MaterialSchema, parseMaterialRef, + type SceneMaterialId, useScene, } from '@pascal-app/core' import { @@ -19,26 +22,34 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Check, Move, Plus, Trash2 } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useRef, useState } from 'react' +import { resolveSlotPaintMaterialRef } from '../shared/slot-paint' import useBlockEditSession from './edit-session' import { assignBlockMaterial, BLOCK_BODY_SLOT_ID, blockMaterialSelection, - collectReusableBlockMaterialRefs, - createBlockMaterialSlot, + createAssignedBlockMaterialSlot, removeBlockMaterialSlot, renameBlockMaterialSlot, - selectBlockFacesByMaterialSlot, - setBlockMaterialSlot, } from './material-slots' import { blockSlots } from './slots' -const REUSABLE_MATERIAL_REF_SEPARATOR = '\u001f' const SLOT_TRAILING_ACTION_CLASS = 'm-2 ml-0 flex w-8 shrink-0 items-center justify-center rounded-md' const SLOT_DISABLED_ACTION_CLASS = 'disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-[#2C2C2E] disabled:active:bg-[#2C2C2E]' +const NEW_BLOCK_SLOT_MATERIAL = { + preset: 'custom', + properties: { + color: '#7768d8', + roughness: 0.75, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, +} satisfies MaterialSchema function materialRefLabel( ref: string | undefined, @@ -83,38 +94,13 @@ export default function BlockPanel() { const nodeRef = useRef(node) nodeRef.current = node const sceneMaterials = useScene((state) => state.materials) - const reusableMaterialRefsKey = useScene((state) => - collectReusableBlockMaterialRefs(Object.values(state.nodes), Object.keys(state.materials)).join( - REUSABLE_MATERIAL_REF_SEPARATOR, - ), - ) - const reusableMaterialRefs = reusableMaterialRefsKey - ? reusableMaterialRefsKey.split(REUSABLE_MATERIAL_REF_SEPARATOR) - : [] const readOnly = useScene((state) => state.readOnly) const editing = useInteractionScope( (state) => state.scope.kind === 'mesh-editing' && state.scope.nodeId === selectedId, ) const sessionNodeId = useBlockEditSession((state) => state.nodeId) const selection = useBlockEditSession((state) => state.selection) - const activeMaterialSlotId = useBlockEditSession((state) => state.activeMaterialSlotId) - - const activeFaceSlotId = useMemo(() => { - if (!(node && sessionNodeId === node.id && selection.mode === 'face')) return null - return node.topology.faces.find((face) => face.id === selection.activeId)?.materialSlot ?? null - }, [node, selection.activeId, selection.mode, sessionNodeId]) - const nodeId = node?.id ?? null - const activeFaceId = - sessionNodeId === nodeId && selection.mode === 'face' ? selection.activeId : null - const syncedActiveFaceRef = useRef(null) - - useEffect(() => { - if (!(nodeId && activeFaceId && activeFaceSlotId)) return - const syncKey = `${nodeId}:${activeFaceId}:${activeFaceSlotId}` - if (syncedActiveFaceRef.current === syncKey) return - syncedActiveFaceRef.current = syncKey - useBlockEditSession.getState().setActiveMaterialSlot(nodeId, activeFaceSlotId) - }, [activeFaceId, activeFaceSlotId, nodeId]) + const [slotNotice, setSlotNotice] = useState<{ nodeId: string; text: string } | null>(null) const close = useCallback(() => { setViewerSelection({ selectedIds: [] }) @@ -162,10 +148,6 @@ export default function BlockPanel() { selection.activeId, ) const slotDeclarations = blockSlots(node) - const activeSlotId = - (sessionNodeId === node.id ? activeMaterialSlotId : materialSelection.activeSlotId) ?? - BLOCK_BODY_SLOT_ID - const activeSlotRef = activeSlotId ? node.slots?.[activeSlotId] : undefined const canOperateOnFaces = editing && selection.mode === 'face' const slotEditTitle = !editing ? 'Enter Edit Mode to use slot actions' @@ -177,25 +159,54 @@ export default function BlockPanel() { faceCountBySlot.set(face.materialSlot, (faceCountBySlot.get(face.materialSlot) ?? 0) + 1) } - const chooseSlot = (slotId: string) => { - useBlockEditSession.getState().setActiveMaterialSlot(node.id, slotId) - } - - const chooseReusableMaterial = (materialRef: string) => { - if (!(activeSlotId && materialRef)) return - const result = setBlockMaterialSlot(node.slots, activeSlotId, materialRef) - if (result.changed) { - useScene.getState().updateNode(node.id, { - slots: result.slots, - }) - triggerSFX('sfx:menu-click') - } - } - const addMaterialSlot = () => { - const result = createBlockMaterialSlot(node.topology, node.slots, node.slotNames) - useScene.getState().updateNode(node.id, { slotNames: result.slotNames }) - useBlockEditSession.getState().setActiveMaterialSlot(node.id, result.slotId) + const scene = useScene.getState() + const resolution = resolveSlotPaintMaterialRef( + scene.materials, + NEW_BLOCK_SLOT_MATERIAL, + undefined, + ) + if (!resolution?.ref) return + const result = createAssignedBlockMaterialSlot( + node.topology, + node.slots, + node.slotNames, + selectedFaceIds, + resolution.ref, + ) + if (!result.changed) return + let committed = false + useScene.setState((current) => { + if (current.readOnly || current.nodes[node.id]?.type !== 'block') return current + committed = true + return { + materials: resolution.newSceneMaterial + ? { + ...current.materials, + [resolution.newSceneMaterial.id as SceneMaterialId]: { + ...resolution.newSceneMaterial, + name: 'Block Accent', + }, + } + : current.materials, + nodes: { + ...current.nodes, + [node.id]: { + ...current.nodes[node.id], + topology: result.topology, + slots: result.slots, + slotNames: result.slotNames, + } as AnyNode, + }, + } + }) + if (!committed) return + useScene.getState().markDirty(node.id) + const faceLabel = selectedFaceIds.length === 1 ? 'face' : 'faces' + setSlotNotice({ + nodeId: node.id, + text: `${result.slotNames[result.slotId] ?? result.slotId} applied to ${selectedFaceIds.length} ${faceLabel} with an accent material. Use Paint (P) to replace it.`, + }) triggerSFX('sfx:menu-click') } @@ -211,23 +222,15 @@ export default function BlockPanel() { useScene.getState().updateNode(node.id, { slotNames }) } - const reusableMaterialLabel = (ref: string) => { - const parsed = parseMaterialRef(ref) - if (!parsed) return ref - return parsed.kind === 'scene' - ? (sceneMaterials[parsed.id as keyof typeof sceneMaterials]?.name ?? ref) - : (getCatalogMaterialById(parsed.id)?.label ?? ref) - } - - const assignMaterial = () => { - if (!activeSlotId) return + const assignSlot = (slotId: string) => { + setSlotNotice(null) const result = assignBlockMaterial( node.topology, node.slots, selectedFaceIds, { kind: 'slot', - slotId: activeSlotId, + slotId, }, node.slotNames, ) @@ -236,28 +239,9 @@ export default function BlockPanel() { topology: result.topology, slots: result.slots, }) - useBlockEditSession.getState().setActiveMaterialSlot(node.id, result.slotId) triggerSFX('sfx:menu-click') } - const filterSelection = (operation: 'select' | 'deselect') => { - if (!(canOperateOnFaces && activeSlotId)) return - const ids = selectBlockFacesByMaterialSlot( - node.topology, - selection.ids, - activeSlotId, - operation, - ) - const activeId = ids.includes(selection.activeId ?? '') - ? selection.activeId - : (ids.at(-1) ?? null) - useBlockEditSession.getState().setSelection(node.id, { - mode: 'face', - ids, - activeId, - }) - } - const removeMaterialSlot = (slotId: string) => { const result = removeBlockMaterialSlot(node.topology, node.slots, slotId, node.slotNames) if (!result.changed) return @@ -266,7 +250,7 @@ export default function BlockPanel() { slots: result.slots, slotNames: result.slotNames, }) - useBlockEditSession.getState().setActiveMaterialSlot(node.id, result.fallbackSlotId) + setSlotNotice(null) triggerSFX('sfx:menu-click') } @@ -312,49 +296,67 @@ export default function BlockPanel() {
} label="Add slot" onClick={addMaterialSlot} - title={slotEditTitle} + title={ + slotEditTitle ?? + (selectedFaceIds.length === 0 ? 'Select one or more faces first' : undefined) + } />
+ {slotNotice?.nodeId === node.id ? ( +
+ {slotNotice.text} +
+ ) : null} +
{slotDeclarations.map((slot, index) => { const ref = node.slots?.[slot.slotId] - const active = activeSlotId === slot.slotId + const active = + materialSelection.kind === 'single' && materialSelection.slotId === slot.slotId const preview = materialRefPreview(ref, sceneMaterials) const faceCount = faceCountBySlot.get(slot.slotId) ?? 0 - const materialLabel = materialRefLabel(ref, sceneMaterials) + const materialLabel = + ref || slot.slotId === BLOCK_BODY_SLOT_ID + ? materialRefLabel(ref, sceneMaterials) + : 'Unpainted' return (
0 ? 'border-t' : '' } ${active ? 'bg-primary/15' : 'hover:bg-white/[0.035]'}`} key={slot.slotId} > + - + { - if (editing) chooseSlot(slot.slotId) - }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() if (event.key === 'Escape') { @@ -379,12 +378,14 @@ export default function BlockPanel() { {slot.slotId === BLOCK_BODY_SLOT_ID ? ( - + {active ? ) : (
- -
- - -
- -
- filterSelection('select')} - title={slotEditTitle} - /> - filterSelection('deselect')} - title={slotEditTitle} - /> - -
diff --git a/packages/nodes/src/block/rotation-drag.test.ts b/packages/nodes/src/block/rotation-drag.test.ts index 61a20a8b10..fd2746f703 100644 --- a/packages/nodes/src/block/rotation-drag.test.ts +++ b/packages/nodes/src/block/rotation-drag.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test' import { Vector3 } from 'three' -import { signedAngleAroundAxis, unwrapRotationDelta } from './rotation-drag' +import { + lockedRotationAngleFromHits, + signedAngleAroundAxis, + unwrapRotationDelta, +} from './rotation-drag' describe('block rotation drag', () => { test('derives rotation direction around the chosen axis', () => { @@ -18,4 +22,26 @@ describe('block rotation drag', () => { expect(unwrapRotationDelta(previous, current)).toBeCloseTo((2 * Math.PI) / 180) expect(unwrapRotationDelta(current, previous)).toBeCloseTo((-2 * Math.PI) / 180) }) + + test('uses the gizmo direction when rotation is locked to Y', () => { + const angle = lockedRotationAngleFromHits( + new Vector3(), + new Vector3(1, 0, 0), + new Vector3(0, 0, -1), + new Vector3(0, 1, 0), + ) + + expect(angle).toBeCloseTo(Math.PI / 2) + }) + + test('waits for a direction when axis locking starts on the pivot', () => { + expect( + lockedRotationAngleFromHits( + new Vector3(), + new Vector3(), + new Vector3(0, 0, -1), + new Vector3(0, 1, 0), + ), + ).toBeNull() + }) }) diff --git a/packages/nodes/src/block/rotation-drag.ts b/packages/nodes/src/block/rotation-drag.ts index b171a66f6e..b9c113272d 100644 --- a/packages/nodes/src/block/rotation-drag.ts +++ b/packages/nodes/src/block/rotation-drag.ts @@ -4,6 +4,18 @@ export function signedAngleAroundAxis(from: Vector3, to: Vector3, axis: Vector3) return Math.atan2(axis.dot(from.clone().cross(to)), from.dot(to)) } +export function lockedRotationAngleFromHits( + origin: Vector3, + initialHit: Vector3, + currentHit: Vector3, + axis: Vector3, +): number | null { + const initialVector = initialHit.clone().sub(origin).projectOnPlane(axis) + const currentVector = currentHit.clone().sub(origin).projectOnPlane(axis) + if (initialVector.lengthSq() < 1e-6 || currentVector.lengthSq() < 1e-6) return null + return signedAngleAroundAxis(initialVector.normalize(), currentVector.normalize(), axis) +} + export function unwrapRotationDelta(previous: number, current: number): number { let delta = current - previous if (delta > Math.PI) delta -= Math.PI * 2 diff --git a/packages/nodes/src/block/selection-geometry.ts b/packages/nodes/src/block/selection-geometry.ts new file mode 100644 index 0000000000..01ec988f82 --- /dev/null +++ b/packages/nodes/src/block/selection-geometry.ts @@ -0,0 +1,38 @@ +import type { BlockTopology } from '@pascal-app/core' +import type { Camera, Object3D } from 'three' +import { Vector2, Vector3 } from 'three' +import { type BlockSelection, blockSelectionVertexIds } from './commands' + +export type BlockPoint = [number, number, number] + +export function blockSelectionCentroid( + topology: BlockTopology, + selection: BlockSelection, +): BlockPoint | null { + const ids = blockSelectionVertexIds(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] +} + +export function blockLocalPointToClient( + point: BlockPoint, + target: Object3D, + camera: Camera, + canvas: HTMLCanvasElement, +): Vector2 | null { + target.updateWorldMatrix(true, false) + const projected = target.localToWorld(new Vector3(...point)).project(camera) + if (![projected.x, projected.y, projected.z].every(Number.isFinite)) return null + const rect = canvas.getBoundingClientRect() + return new Vector2( + rect.left + ((projected.x + 1) / 2) * rect.width, + rect.top + ((1 - projected.y) / 2) * rect.height, + ) +} diff --git a/packages/nodes/src/block/selection-model.test.ts b/packages/nodes/src/block/selection-model.test.ts index 69bd141edd..f53635759e 100644 --- a/packages/nodes/src/block/selection-model.test.ts +++ b/packages/nodes/src/block/selection-model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { createBoxBlockTopology } from '@pascal-app/core' import { + blockSelectionChanged, convertBlockSelection, createBlockSelection, invertBlockSelection, @@ -39,4 +40,10 @@ describe('block component selection', () => { expect(inverse.ids).toHaveLength(10) expect(inverse.ids).not.toContain('e0') }) + + test('treats an identical selection as a no-op', () => { + const selection = createBlockSelection('face') + expect(blockSelectionChanged(selection, { ...selection, ids: [...selection.ids] })).toBe(false) + expect(blockSelectionChanged(selection, createBlockSelection('face', ['f-top']))).toBe(true) + }) }) diff --git a/packages/nodes/src/block/selection-model.ts b/packages/nodes/src/block/selection-model.ts index 7f51022f5d..ad82e8df06 100644 --- a/packages/nodes/src/block/selection-model.ts +++ b/packages/nodes/src/block/selection-model.ts @@ -11,6 +11,18 @@ export type BlockSelectionState = BlockSelection & { activeId: string | null } +export function blockSelectionChanged( + previous: BlockSelectionState, + next: BlockSelectionState, +): boolean { + return ( + previous.mode !== next.mode || + previous.activeId !== next.activeId || + previous.ids.length !== next.ids.length || + previous.ids.some((id, index) => id !== next.ids[index]) + ) +} + function idsForMode(topology: BlockTopology, mode: BlockComponentMode): string[] { switch (mode) { case 'vertex': diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 340f02bd64..c65a8c326a 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -1,14 +1,12 @@ 'use client' import { - type AnyNodeId, type BlockFace, type BlockNode, type BlockTopology, emitter, sceneRegistry, useLiveNodeOverrides, - useScene, } from '@pascal-app/core' import { cn, @@ -16,9 +14,11 @@ import { getFloatingMenuScale, isAngleSnapActive, isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, meshEditScope, NodeActionMenu, + type SelectionAffordanceProps, swallowNextClick, triggerSFX, useEditor, @@ -36,6 +36,7 @@ import { Eye, EyeOff, Move3D, + Rotate3D, Rows3, Scaling, ScanLine, @@ -55,12 +56,15 @@ import { import { BufferGeometry, type Camera, + Color, ConeGeometry, CylinderGeometry, DoubleSide, Float32BufferAttribute, type Group, LineSegments, + type Material, + Mesh, type Object3D, Plane, PlaneGeometry, @@ -79,16 +83,58 @@ import { blockFaceCentroid, blockFaceNormal, blockLoopCutSegments, - blockSelectionVertexIds, } from './commands' import useBlockEditSession from './edit-session' import { triangulateBlockFace } from './geometry' +import { blockGeometrySnapThreshold, resolveBlockGeometrySnap } from './geometry-snap' import { BLOCK_WHEEL_OPTIONS, consumeBlockGestureWheel } from './gesture-wheel' import { type BlockSfxAction, blockSfx } from './interaction-sfx' +import { + type BlockLastOperation, + recordCommittedBlockOperation, + repeatCommittedBlockOperation, + replaceCommittedBlockOperation, +} from './last-operation' import { resolveLoopCutPointerAction, resolveLoopCutSlideFactor } from './loop-cut-interaction' -import { signedAngleAroundAxis, unwrapRotationDelta } from './rotation-drag' +import { BLOCK_BODY_SLOT_ID, unpaintedBlockMaterialSlotIds } from './material-slots' +import { type BlockModalFaceOperation, blockModalFaceOperationStatus } from './modal-face-operation' +import { beginBlockModalSession } from './modal-session' +import { + type BlockActiveTransform, + type BlockAxisVisualState, + type BlockModalFeedbackMode, + type BlockTransformAxis, + type BlockTransformConstraint, + type BlockTransformOperation, + type BlockTransformPlane, + blockAccumulatePrecisionPointer, + blockAxisDelta, + blockAxisVisualState, + blockConstrainTranslationDelta, + blockModalTransformStatus, + blockNumericDeltaForConstraint, + blockPlaneVisualState, + blockPrecisionSnapStep, + blockRotationPointerAngle, + blockScaleFactorsForConstraint, + blockTransformConstraintFromKey, + blockTransformDisplayValue, + blockTransformNumericInputFromKey, + blockTransformNumericValue, +} from './modal-transform' +import { + lockedRotationAngleFromHits, + signedAngleAroundAxis, + unwrapRotationDelta, +} from './rotation-drag' +import { + blockLocalPointToClient as localPointToClient, + type BlockPoint as Point, + blockSelectionCentroid as selectionCentroid, +} from './selection-geometry' import { type BlockSelectionState, + blockSelectionChanged, clearBlockSelection, convertBlockSelection, invertBlockSelection, @@ -105,16 +151,13 @@ import { blockToolbarOffset, formatBlockSelectionStatus, } from './toolbar-state' +import { useBlockFaceOperation } from './use-block-face-operation' type ComponentMode = BlockSelection['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 | 'uniform' -} +type Axis = BlockTransformAxis +type PlaneAxes = BlockTransformPlane +type TransformOperation = BlockTransformOperation +type ActiveTransform = BlockActiveTransform type TransformTool = 'transform' | 'loop-cut' | 'bevel' type TopologyOperator = 'extrude' | 'inset' | 'merge' | 'dissolve' | 'delete' type ToolbarPanel = 'operations' | 'selection' | null @@ -154,6 +197,14 @@ const OPERATION_INPUT_CLASS = const playBlockSfx = (action: BlockSfxAction) => triggerSFX(blockSfx(action)) +function isAxisConstraint(constraint: BlockTransformConstraint): constraint is Axis { + return constraint === 'x' || constraint === 'y' || constraint === 'z' +} + +function isPlaneConstraint(constraint: BlockTransformConstraint): constraint is PlaneAxes { + return constraint === 'xy' || constraint === 'xz' || constraint === 'yz' +} + function preferredFace(topology: BlockTopology): BlockFace | null { return ( topology.faces @@ -172,19 +223,6 @@ function topologyVertexMap(topology: BlockTopology): Map { return new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) } -function selectionCentroid(topology: BlockTopology, selection: BlockSelection): Point | null { - const ids = blockSelectionVertexIds(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: BlockTopology): number { const axes = [0, 1, 2] as const return Math.max( @@ -211,20 +249,21 @@ function closestAxisParameterToRay( return e + b * axisParameter < 0 ? -d : axisParameter } -function localPointToClient( - point: Point, - target: Object3D, +function geometrySnapThreshold( camera: Camera, + worldPoint: Vector3, + target: Object3D, canvas: HTMLCanvasElement, -): Vector2 | null { + extent: number, +): number { target.updateWorldMatrix(true, false) - const projected = target.localToWorld(new Vector3(...point)).project(camera) - if (![projected.x, projected.y, projected.z].every(Number.isFinite)) return null - const rect = canvas.getBoundingClientRect() - return new Vector2( - rect.left + ((projected.x + 1) / 2) * rect.width, - rect.top + ((1 - projected.y) / 2) * rect.height, + const screenThreshold = blockGeometrySnapThreshold( + camera, + worldPoint, + canvas.getBoundingClientRect().height, + target.getWorldScale(new Vector3()), ) + return Math.min(extent * 0.15, Math.max(0.02, screenThreshold)) } function VertexHandle({ @@ -628,16 +667,18 @@ function AxisTransformHandle({ axis, length, radius, - moveActive, - scaleActive, + moveState, + scaleState, + disabled, onMovePointerDown, onScalePointerDown, }: { axis: Axis length: number radius: number - moveActive: boolean - scaleActive: boolean + moveState: BlockAxisVisualState + scaleState: BlockAxisVisualState + disabled: boolean onMovePointerDown: (axis: Axis, event: ThreeEvent) => void onScalePointerDown: (axis: Axis, event: ThreeEvent) => void }) { @@ -689,12 +730,14 @@ function AxisTransformHandle({ ) useEffect(() => { moveMaterial.color.set( - moveActive || hovered === 'translate' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], + hovered === 'translate' && moveState !== 'faded' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], ) + moveMaterial.opacity = moveState === 'faded' ? 0.14 : 1 scaleMaterial.color.set( - scaleActive || hovered === 'scale' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], + hovered === 'scale' && scaleState !== 'faded' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], ) - }, [axis, hovered, moveActive, moveMaterial, scaleActive, scaleMaterial]) + scaleMaterial.opacity = scaleState === 'faded' ? 0.14 : 1 + }, [axis, hovered, moveMaterial, moveState, scaleMaterial, scaleState]) useEffect( () => () => { shaftGeometry.dispose() @@ -744,12 +787,14 @@ function AxisTransformHandle({ layers={EDITOR_LAYER} material={hitMaterial} onPointerDown={(event) => { + if (disabled) return event.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() onMovePointerDown(axis, event) }} onPointerEnter={(event) => { + if (disabled) return event.stopPropagation() setHovered('translate') document.body.style.cursor = 'grab' @@ -774,12 +819,14 @@ function AxisTransformHandle({ layers={EDITOR_LAYER} material={hitMaterial} onPointerDown={(event) => { + if (disabled) return event.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() onScalePointerDown(axis, event) }} onPointerEnter={(event) => { + if (disabled) return event.stopPropagation() setHovered('scale') document.body.style.cursor = 'grab' @@ -799,14 +846,16 @@ function PlaneMoveHandle({ plane, offset, size, - active, + state, + disabled, onPointerDown, }: { plane: PlaneAxes offset: number size: number - active: boolean - onPointerDown: (axis: Axis, event: ThreeEvent) => void + state: BlockAxisVisualState + disabled: boolean + onPointerDown: (constraint: Axis | PlaneAxes, event: ThreeEvent) => void }) { const [hovered, setHovered] = useState(false) const geometry = useMemo(() => new PlaneGeometry(size, size), [size]) @@ -837,8 +886,9 @@ function PlaneMoveHandle({ [], ) useEffect(() => { - material.color.set(active || hovered ? PIVOT_HOVERED_COLOR : AXIS_COLORS[normalAxis]) - }, [active, hovered, material, normalAxis]) + material.color.set(hovered && state !== 'faded' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[normalAxis]) + material.opacity = state === 'faded' ? 0.1 : 1 + }, [hovered, material, normalAxis, state]) useEffect( () => () => { geometry.dispose() @@ -871,12 +921,14 @@ function PlaneMoveHandle({ layers={EDITOR_LAYER} material={hitMaterial} onPointerDown={(event) => { + if (disabled) return event.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() - onPointerDown(normalAxis, event) + onPointerDown(plane, event) }} onPointerEnter={(event) => { + if (disabled) return event.stopPropagation() setHovered(true) document.body.style.cursor = 'move' @@ -895,13 +947,15 @@ function RotationHandle({ axis, radius, tube, - active, + state, + disabled, onPointerDown, }: { axis: Axis radius: number tube: number - active: boolean + state: BlockAxisVisualState + disabled: boolean onPointerDown: (axis: Axis, event: ThreeEvent) => void }) { const [hovered, setHovered] = useState(false) @@ -935,8 +989,9 @@ function RotationHandle({ [axis], ) useEffect(() => { - material.color.set(active || hovered ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis]) - }, [active, axis, hovered, material]) + material.color.set(hovered && state !== 'faded' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis]) + material.opacity = state === 'faded' ? 0.14 : 1 + }, [axis, hovered, material, state]) useEffect( () => () => { ringGeometry.dispose() @@ -963,12 +1018,14 @@ function RotationHandle({ layers={EDITOR_LAYER} material={hitMaterial} onPointerDown={(event) => { + if (disabled) return event.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() onPointerDown(axis, event) }} onPointerEnter={(event) => { + if (disabled) return event.stopPropagation() setHovered(true) document.body.style.cursor = 'grab' @@ -1255,12 +1312,170 @@ function ToolbarPanelFrame({ ) } +function LastOperationControls({ + operation, + onChange, +}: { + operation: BlockLastOperation + onChange: (command: BlockCommand) => void +}) { + const command = operation.command + const input = ( + label: string, + value: number, + update: (value: number) => BlockCommand, + options: { min?: number; max?: number; step?: number } = {}, + ) => ( + + ) + + switch (command.type) { + case 'translate-components': + return ( +
+ {(['X', 'Y', 'Z'] as const).map((axis, index) => + input(`${axis} distance`, command.delta[index]!, (value) => ({ + ...command, + delta: command.delta.map((current, currentIndex) => + currentIndex === index ? value : current, + ) as Point, + })), + )} +
+ ) + case 'rotate-components': + return input( + 'Angle', + (command.angle * 180) / Math.PI, + (value) => ({ ...command, angle: (value * Math.PI) / 180 }), + { step: 1 }, + ) + case 'scale-components': + return ( +
+ {(['X', 'Y', 'Z'] as const).map((axis, index) => + input(`${axis} scale`, command.factors[index]!, (value) => ({ + ...command, + factors: command.factors.map((current, currentIndex) => + currentIndex === index ? value : current, + ) as Point, + })), + )} +
+ ) + case 'extrude-faces': + return input('Distance', command.distance, (distance) => ({ ...command, distance })) + case 'inset-faces': + return input('Amount', command.amount, (amount) => ({ ...command, amount }), { + min: 0, + max: 0.95, + }) + case 'bevel-edges': + return ( +
+ {input('Width', command.width, (width) => ({ ...command, width }), { min: 0 })} + {input( + 'Segments', + command.segments, + (segments) => ({ ...command, segments: Math.min(12, Math.max(1, segments)) }), + { min: 1, max: 12, step: 1 }, + )} +
+ ) + case 'loop-cut': + return ( +
+ {input('Position', command.factor, (factor) => ({ ...command, factor }), { + min: 0.02, + max: 0.98, + })} + {input( + 'Cuts', + command.cuts ?? 1, + (cuts) => ({ ...command, cuts: Math.min(32, Math.max(1, cuts)) }), + { min: 1, max: 32, step: 1 }, + )} +
+ ) + default: + return null + } +} + +function LastOperationPanel({ + operation, + onChange, + onClose, + onRepeat, +}: { + operation: BlockLastOperation + onChange: (command: BlockCommand) => void + onClose: () => void + onRepeat: () => void +}) { + return ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + role="dialog" + > +
+
+
{operation.label}
+
Adjust Last Operation · F9
+
+ +
+ + +
+ ) +} + function BlockEditor({ + historyApi, node, + readOnly, + sceneApi, target, mirrorTarget, }: { + historyApi: SelectionAffordanceProps['historyApi'] node: BlockNode + readOnly: boolean + sceneApi: SelectionAffordanceProps['sceneApi'] target: Object3D mirrorTarget: boolean }) { @@ -1280,22 +1495,35 @@ function BlockEditor({ const activeId = useBlockEditSession((state) => state.nodeId === node.id ? state.selection.activeId : null, ) + const lastOperation = useBlockEditSession((state) => + state.nodeId === node.id ? state.lastOperation : null, + ) const [transformTool, setTransformTool] = useState('transform') const [xray, setXray] = useState(false) const [previewTopology, setPreviewTopology] = useState(null) const [activeTransform, setActiveTransform] = useState(null) + const [transformNumericInput, setTransformNumericInput] = useState('') + const [modalFeedbackMode, setModalFeedbackMode] = useState('free') + const [activeFaceOperation, setActiveFaceOperation] = useState( + null, + ) + const [faceOperationValue, setFaceOperationValue] = useState('') 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 [bevelWidth, setBevelWidth] = useState(0) + const [lastOperationPanelOpen, setLastOperationPanelOpen] = useState(false) const [toolbarPanel, setToolbarPanel] = useState(null) const [error, setError] = useState(null) const cancelDragRef = useRef<(() => void) | null>(null) const lastPointerClientRef = useRef(null) + const operationServices = useMemo( + () => ({ historyApi, readOnly, sceneApi }), + [historyApi, readOnly, sceneApi], + ) const displayTopology = previewTopology ?? node.topology const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) const selection = useMemo(() => ({ mode, ids: selectedIds }), [mode, selectedIds]) @@ -1353,30 +1581,34 @@ function BlockEditor({ cancelDragRef.current?.() cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) endOwnedScope() useBlockEditSession.getState().end(node.id) setPreviewTopology(null) setTransformTool('transform') setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') + setActiveFaceOperation(null) + setFaceOperationValue('') setLoopCutSegments(null) setLoopCutEdgeId(null) setLoopCutSliding(false) setToolbarPanel(null) setError(null) playBlockSfx('finish') - }, [endOwnedScope, node.id]) + }, [endOwnedScope, node.id, sceneApi.markDirty]) useEffect( () => () => { cancelDragRef.current?.() useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) endOwnedScope() useBlockEditSession.getState().end(node.id) if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' }, - [endOwnedScope, node.id], + [endOwnedScope, node.id, sceneApi.markDirty], ) useEffect(() => { @@ -1384,15 +1616,58 @@ function BlockEditor({ cancelDragRef.current?.() cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setPreviewTopology(null) setToolbarPanel(null) setLoopCutSegments(null) setLoopCutEdgeId(null) setLoopCutSliding(false) setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') + setActiveFaceOperation(null) + setFaceOperationValue('') useBlockEditSession.getState().end(node.id) - }, [editing, node.id]) + }, [editing, node.id, sceneApi.markDirty]) + + useEffect(() => { + if (!editing) return + const unpaintedSlotIds = new Set( + unpaintedBlockMaterialSlotIds(node.topology, node.slots, node.slotNames), + ) + if (unpaintedSlotIds.size === 0) return + + const restores: Array<{ mesh: Mesh; material: Material | Material[] }> = [] + const ownedMaterials: Material[] = [] + target.traverse((child) => { + if (!(child instanceof Mesh)) return + const slotIds = Array.isArray(child.userData.slotIds) + ? (child.userData.slotIds as string[]) + : [] + if (!slotIds.some((slotId) => unpaintedSlotIds.has(slotId))) return + const previousMaterial = child.material + const sourceMaterials = Array.isArray(previousMaterial) + ? previousMaterial + : [previousMaterial] + const nextMaterials = sourceMaterials.map((material, index) => { + const slotId = slotIds[index] + if (!(slotId && slotId !== BLOCK_BODY_SLOT_ID && unpaintedSlotIds.has(slotId))) { + return material + } + const tinted = material.clone() + if ('color' in tinted && tinted.color instanceof Color) tinted.color.set('#7768d8') + ownedMaterials.push(tinted) + return tinted + }) + restores.push({ mesh: child, material: previousMaterial }) + child.material = Array.isArray(previousMaterial) ? nextMaterials : nextMaterials[0]! + }) + + return () => { + for (const restore of restores) restore.mesh.material = restore.material + for (const material of ownedMaterials) material.dispose() + } + }, [editing, node.slotNames, node.slots, node.topology, target]) useEffect(() => { if (!editing) return @@ -1439,7 +1714,10 @@ function BlockEditor({ const onGridClick = () => { const scope = useInteractionScope.getState().scope if (scope.kind !== 'mesh-editing' || scope.nodeId !== node.id || cancelDragRef.current) return - useBlockEditSession.getState().setSelection(node.id, { mode, ids: [], activeId: null }) + const session = useBlockEditSession.getState() + const next = { mode, ids: [], activeId: null } + if (!blockSelectionChanged(session.selection, next)) return + session.setSelection(node.id, next) setError(null) playBlockSfx('component-select') } @@ -1563,6 +1841,7 @@ function BlockEditor({ (id: string, additive: boolean, event: ThreeEvent) => { if (!componentIsVisible(id, event)) return const next = selectBlockComponent({ mode, ids: selectedIds, activeId }, id, additive) + if (!blockSelectionChanged({ mode, ids: selectedIds, activeId }, next)) return useBlockEditSession.getState().setSelection(node.id, next) setError(null) playBlockSfx('component-select') @@ -1596,21 +1875,426 @@ function BlockEditor({ [camera, gl.domElement], ) + const commitAdjustableOperation = useCallback( + (baseTopology: BlockTopology, command: BlockCommand, label: string) => { + const result = applyBlockCommand(baseTopology, command) + if (!result.ok) { + setError(result.error) + return false + } + sceneApi.update(node.id, { topology: result.topology }) + const session = useBlockEditSession.getState() + session.setSelection(node.id, { + ...result.selection, + activeId: result.selection.ids.at(-1) ?? null, + }) + session.setLastOperation( + node.id, + recordCommittedBlockOperation( + operationServices, + node.id, + label, + baseTopology, + command, + result, + ), + ) + setLastOperationPanelOpen(true) + setError(null) + return true + }, + [node.id, operationServices, sceneApi], + ) + + const beginKeyboardTransformModal = useCallback( + (operation: 'translate' | 'rotate') => { + if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return false + const origin = selectionCentroid(displayTopology, selection) + if (!origin) return false + const pivotClient = localPointToClient(origin, target, camera, gl.domElement) + if (!pivotClient) return false + + target.updateWorldMatrix(true, false) + const originLocal = new Vector3(...origin) + const worldOrigin = target.localToWorld(originLocal.clone()) + const startPointer = + lastPointerClientRef.current?.clone() ?? pivotClient.clone().add(new Vector2(80, 0)) + const startRay = makeRay(startPointer.x, startPointer.y) + const viewAxisWorld = camera.getWorldDirection(new Vector3()).normalize() + const viewPlane = new Plane().setFromNormalAndCoplanarPoint(viewAxisWorld, worldOrigin) + const startPlaneHit = startRay.intersectPlane(viewPlane, new Vector3()) ?? worldOrigin.clone() + const targetWorldQuaternion = target.getWorldQuaternion(new Quaternion()) + const freeRotationAxis = viewAxisWorld + .clone() + .applyQuaternion(targetWorldQuaternion.clone().invert()) + .normalize() + const baseTopology = displayTopology + const baseSelection = selection + let activeConstraint: Axis | PlaneAxes | null = null + let latestTopology: BlockTopology | null = null + let latestCommand: BlockCommand | null = null + let latestMagnitude = 0 + let previousWrappedAngle = 0 + let accumulatedAngle = 0 + let lockedRotationInitialHit: Vector3 | null = null + let lockedRotationPlane: Plane | null = null + let lockedRotationWorldAxis: Vector3 | null = null + let lockedTranslationInitialHit: Vector3 | null = null + let lockedTranslationPlane: Plane | null = null + let lastClientX = startPointer.x + let lastClientY = startPointer.y + let lastAltKey = false + let lastShiftKey = false + let effectivePointer = { x: startPointer.x, y: startPointer.y } + let previousRawPointer = { x: startPointer.x, y: startPointer.y } + let lastSnapValue: string | number | null = null + let typedInput = '' + + const worldAxisFor = (axis: Axis) => + target + .localToWorld(originLocal.clone().add(new Vector3(...AXIS_VECTORS[axis]))) + .sub(worldOrigin) + .normalize() + + const updatePreview = ( + clientX: number, + clientY: number, + altKey: boolean, + precision: boolean, + ) => { + lastClientX = clientX + lastClientY = clientY + lastAltKey = altKey + lastShiftKey = precision + const ray = makeRay(clientX, clientY) + const numericValue = blockTransformNumericValue(typedInput, operation) + let command: BlockCommand + let snapValue: string | number + + if (operation === 'translate') { + let delta: Point + if (activeConstraint && isAxisConstraint(activeConstraint)) { + const worldAxis = worldAxisFor(activeConstraint) + const startParameter = closestAxisParameterToRay(worldOrigin, worldAxis, startRay) + const currentParameter = closestAxisParameterToRay(worldOrigin, worldAxis, ray) + const localPoint = target.worldToLocal( + worldOrigin.clone().addScaledVector(worldAxis, currentParameter - startParameter), + ) + const axisIndex = activeConstraint === 'x' ? 0 : activeConstraint === 'y' ? 1 : 2 + delta = blockAxisDelta( + activeConstraint, + localPoint.getComponent(axisIndex) - origin[axisIndex], + ) + } else if ( + activeConstraint && + isPlaneConstraint(activeConstraint) && + lockedTranslationInitialHit && + lockedTranslationPlane + ) { + const currentHit = ray.intersectPlane(lockedTranslationPlane, new Vector3()) + if (!currentHit) return + const localPoint = target.worldToLocal( + worldOrigin.clone().add(currentHit.sub(lockedTranslationInitialHit)), + ) + delta = blockConstrainTranslationDelta( + [localPoint.x - origin[0], localPoint.y - origin[1], localPoint.z - origin[2]], + activeConstraint, + ) + } else { + const currentHit = ray.intersectPlane(viewPlane, new Vector3()) + if (!currentHit) return + const localPoint = target.worldToLocal( + worldOrigin.clone().add(currentHit.clone().sub(startPlaneHit)), + ) + delta = [localPoint.x - origin[0], localPoint.y - origin[1], localPoint.z - origin[2]] + } + if (numericValue !== null) { + delta = blockNumericDeltaForConstraint(activeConstraint ?? 'free', delta, numericValue) + } + const snapping = numericValue === null && isGridSnapActive() && !altKey + if (snapping) { + const step = blockPrecisionSnapStep(useEditor.getState().gridSnapStep, precision) + if (step > 0) delta = delta.map((value) => Math.round(value / step) * step) as Point + } + const geometrySnap = + numericValue === null && isMagneticSnapActive() && !altKey + ? resolveBlockGeometrySnap( + baseTopology, + baseSelection, + delta, + activeConstraint ?? 'free', + geometrySnapThreshold(camera, worldOrigin, target, gl.domElement, extent), + ) + : null + if (geometrySnap) delta = geometrySnap.delta + latestMagnitude = Math.hypot(...delta) + const signedDistance = + activeConstraint && isAxisConstraint(activeConstraint) + ? delta[activeConstraint === 'x' ? 0 : activeConstraint === 'y' ? 1 : 2] + : latestMagnitude + setTransformNumericInput( + typedInput || blockTransformDisplayValue('translate', signedDistance), + ) + setModalFeedbackMode( + typedInput + ? 'exact' + : geometrySnap + ? 'geometry' + : precision + ? 'precision' + : snapping + ? 'grid' + : 'free', + ) + snapValue = geometrySnap + ? `${geometrySnap.kind}:${geometrySnap.targetId}` + : delta.join(':') + if ((snapping || geometrySnap) && latestMagnitude > 1e-6 && snapValue !== lastSnapValue) { + playBlockSfx('move-step') + } + command = { type: 'translate-components', selection: baseSelection, delta } + } else { + let wrappedAngle: number + if ( + activeConstraint?.length === 1 && + lockedRotationInitialHit && + lockedRotationPlane && + lockedRotationWorldAxis + ) { + const currentHit = ray.intersectPlane(lockedRotationPlane, new Vector3()) + if (!currentHit) return + const lockedAngle = lockedRotationAngleFromHits( + worldOrigin, + lockedRotationInitialHit, + currentHit, + lockedRotationWorldAxis, + ) + if (lockedAngle === null) { + if (currentHit.distanceToSquared(worldOrigin) > 1e-6) { + lockedRotationInitialHit = currentHit.clone() + } + wrappedAngle = 0 + } else { + wrappedAngle = lockedAngle + } + } else { + wrappedAngle = blockRotationPointerAngle( + pivotClient, + startPointer, + new Vector2(clientX, clientY), + ) + } + accumulatedAngle += unwrapRotationDelta(previousWrappedAngle, wrappedAngle) + previousWrappedAngle = wrappedAngle + let angle = accumulatedAngle + if (numericValue !== null) angle = numericValue + const snapping = numericValue === null && isAngleSnapActive() && !altKey + if (snapping) { + const step = + (blockPrecisionSnapStep(ROTATION_SNAP_ANGLE_DEGREES, precision) * Math.PI) / 180 + angle = Math.round(angle / step) * step + } + latestMagnitude = Math.abs(angle) + setTransformNumericInput(typedInput || blockTransformDisplayValue('rotate', angle)) + setModalFeedbackMode( + typedInput ? 'exact' : precision ? 'precision' : snapping ? 'angle' : 'free', + ) + snapValue = angle + if (snapping && latestMagnitude > 1e-6 && snapValue !== lastSnapValue) { + playBlockSfx('rotate-step') + } + command = { + type: 'rotate-components', + selection: baseSelection, + pivot: origin, + axis: + activeConstraint && isAxisConstraint(activeConstraint) + ? AXIS_VECTORS[activeConstraint] + : (freeRotationAxis.toArray() as Point), + angle, + } + } + + lastSnapValue = snapValue + const result = applyBlockCommand(baseTopology, command) + if (!result.ok) { + setError(result.error) + return + } + latestTopology = result.topology + latestCommand = command + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + sceneApi.markDirty(node.id) + setError(null) + } + + const complete = (commit: boolean) => { + useLiveNodeOverrides.getState().clear(node.id) + sceneApi.markDirty(node.id) + setPreviewTopology(null) + setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') + if (commit && latestTopology && latestCommand && latestMagnitude > 1e-6) { + commitAdjustableOperation( + baseTopology, + latestCommand, + operation === 'translate' ? 'Move' : 'Rotate', + ) + playBlockSfx('finish') + } else if (!commit) { + playBlockSfx('cancel') + } + if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) + swallowNextClick() + } + + const onMove = (pointerEvent: PointerEvent) => { + lastPointerClientRef.current = new Vector2(pointerEvent.clientX, pointerEvent.clientY) + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + updatePreview( + effectivePointer.x, + effectivePointer.y, + pointerEvent.altKey, + pointerEvent.shiftKey, + ) + } + const onPointerDown = (pointerEvent: PointerEvent, finish: (commit: boolean) => void) => { + if (pointerEvent.button !== 0 && pointerEvent.button !== 2) return + pointerEvent.preventDefault() + pointerEvent.stopImmediatePropagation() + finish(pointerEvent.button === 0) + } + const onKeyDown = (keyboardEvent: KeyboardEvent, finish: (commit: boolean) => void) => { + const element = keyboardEvent.target as HTMLElement | null + if ( + element?.tagName === 'INPUT' || + element?.tagName === 'TEXTAREA' || + element?.isContentEditable + ) + return + const constraint = blockTransformConstraintFromKey( + keyboardEvent.key, + operation === 'translate' && keyboardEvent.shiftKey, + ) + if (constraint) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + activeConstraint = constraint + if (operation === 'rotate') { + const axis = constraint as Axis + lockedRotationWorldAxis = worldAxisFor(axis) + lockedRotationPlane = new Plane().setFromNormalAndCoplanarPoint( + lockedRotationWorldAxis, + worldOrigin, + ) + lockedRotationInitialHit = makeRay(lastClientX, lastClientY).intersectPlane( + lockedRotationPlane, + new Vector3(), + ) + previousWrappedAngle = 0 + accumulatedAngle = 0 + } else if (isPlaneConstraint(constraint)) { + const normalAxis = PLANE_NORMAL[constraint] + lockedTranslationPlane = new Plane().setFromNormalAndCoplanarPoint( + worldAxisFor(normalAxis), + worldOrigin, + ) + lockedTranslationInitialHit = startRay.intersectPlane( + lockedTranslationPlane, + new Vector3(), + ) + } else { + lockedTranslationPlane = null + lockedTranslationInitialHit = null + } + setActiveTransform({ operation, constraint }) + lastSnapValue = null + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else { + const nextInput = blockTransformNumericInputFromKey(typedInput, keyboardEvent.key) + if (nextInput !== null) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + typedInput = nextInput + setTransformNumericInput(nextInput) + lastSnapValue = null + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else if (keyboardEvent.key === 'Enter') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(true) + } else if (keyboardEvent.key === 'Escape') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(false) + } + } + } + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operation)) + playBlockSfx('drag-start') + setTransformTool('transform') + setToolbarPanel(null) + setActiveTransform({ operation, constraint: 'free' }) + setTransformNumericInput('') + setModalFeedbackMode('free') + setError(null) + beginBlockModalSession({ + cancelRef: cancelDragRef, + cursor: operation === 'translate' ? 'move' : 'crosshair', + onFinish: complete, + onKeyDown, + onPointerDown, + onPointerMove: onMove, + }) + return true + }, + [ + camera, + commitAdjustableOperation, + displayTopology, + extent, + gl.domElement, + makeRay, + node.id, + ownsEditSession, + selectedIds.length, + selection, + target, + sceneApi.markDirty, + ], + ) + const beginTranslationDrag = useCallback( - (axis: Axis, event: ThreeEvent) => { + (constraint: Axis | PlaneAxes, 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 normalAxis = isPlaneConstraint(constraint) ? PLANE_NORMAL[constraint] : constraint + const localAxis = new Vector3(...AXIS_VECTORS[normalAxis]) 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 dragPlane = isPlaneConstraint(constraint) + ? new Plane().setFromNormalAndCoplanarPoint(worldAxis, worldOrigin) + : null + const initialPlaneHit = dragPlane ? event.ray.intersectPlane(dragPlane, new Vector3()) : null + if (dragPlane && !initialPlaneHit) return + const initialParameter = isAxisConstraint(constraint) + ? closestAxisParameterToRay(worldOrigin, worldAxis, event.ray) + : 0 + const axisIndex = normalAxis === 'x' ? 0 : normalAxis === 'y' ? 1 : 2 const baseTopology = displayTopology const baseSelection = selection const previousInputDragging = useViewer.getState().inputDragging @@ -1619,35 +2303,90 @@ function BlockEditor({ let latestDelta: Point = [0, 0, 0] let lastSnapDelta: string | null = null let finished = false + let effectivePointer = { x: event.nativeEvent.clientX, y: event.nativeEvent.clientY } + let previousRawPointer = { ...effectivePointer } useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'translate')) playBlockSfx('drag-start') useViewer.getState().setInputDragging(true) - setActiveTransform({ operation: 'translate', constraint: axis }) + setActiveTransform({ operation: 'translate', constraint }) + setTransformNumericInput('0') + setModalFeedbackMode('free') 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) + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + const ray = makeRay(effectivePointer.x, effectivePointer.y) + let delta: Point + if (dragPlane && initialPlaneHit) { + const currentHit = ray.intersectPlane(dragPlane, new Vector3()) + if (!currentHit) return + const localPoint = target.worldToLocal( + worldOrigin.clone().add(currentHit.sub(initialPlaneHit)), + ) + delta = [localPoint.x - origin[0], localPoint.y - origin[1], localPoint.z - origin[2]] + delta[axisIndex] = 0 + } else { + delta = [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 + const step = blockPrecisionSnapStep( + useEditor.getState().gridSnapStep, + pointerEvent.shiftKey, + ) if (step > 0) { - delta[axisIndex] = Math.round(delta[axisIndex] / step) * step + delta = delta.map((value) => Math.round(value / step) * step) as Point } } + const geometrySnap = + isMagneticSnapActive() && !pointerEvent.altKey + ? resolveBlockGeometrySnap( + baseTopology, + baseSelection, + delta, + constraint, + geometrySnapThreshold(camera, worldOrigin, target, gl.domElement, extent), + ) + : null + if (geometrySnap) delta.splice(0, 3, ...geometrySnap.delta) const snapDelta = delta.join(':') const magnitude = Math.hypot(...delta) - if (snapping && magnitude > 1e-6 && snapDelta !== lastSnapDelta) { - lastSnapDelta = snapDelta + setTransformNumericInput( + blockTransformDisplayValue( + 'translate', + isAxisConstraint(constraint) ? delta[axisIndex] : Math.hypot(...delta), + ), + ) + setModalFeedbackMode( + geometrySnap + ? 'geometry' + : pointerEvent.shiftKey + ? 'precision' + : snapping + ? 'grid' + : 'free', + ) + const activeSnap = geometrySnap + ? `${geometrySnap.kind}:${geometrySnap.targetId}` + : snapDelta + if ((snapping || geometrySnap) && magnitude > 1e-6 && activeSnap !== lastSnapDelta) { + lastSnapDelta = activeSnap playBlockSfx('move-step') - } else if (!snapping) { + } else if (!(snapping || geometrySnap)) { lastSnapDelta = null } const result = applyBlockCommand(baseTopology, { @@ -1663,7 +2402,7 @@ function BlockEditor({ latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) } const finish = (commit: boolean) => { @@ -1675,13 +2414,19 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') if (commit && latestTopology && Math.hypot(...latestDelta) > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) + commitAdjustableOperation( + baseTopology, + { type: 'translate-components', selection: baseSelection, delta: latestDelta }, + 'Move', + ) playBlockSfx('finish') } else if (!commit) { playBlockSfx('cancel') @@ -1699,7 +2444,20 @@ function BlockEditor({ window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, - [displayTopology, makeRay, node.id, ownsEditSession, selectedIds.length, selection, target], + [ + camera, + commitAdjustableOperation, + displayTopology, + extent, + gl.domElement, + makeRay, + node.id, + ownsEditSession, + selectedIds.length, + selection, + target, + sceneApi.markDirty, + ], ) const beginRotationDrag = useCallback( @@ -1732,15 +2490,26 @@ function BlockEditor({ let lastSnapAngle: number | null = null let latestTopology: BlockTopology | null = null let finished = false + let effectivePointer = { x: event.nativeEvent.clientX, y: event.nativeEvent.clientY } + let previousRawPointer = { ...effectivePointer } useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'rotate')) playBlockSfx('drag-start') useViewer.getState().setInputDragging(true) setActiveTransform({ operation: 'rotate', constraint: axis }) + setTransformNumericInput('0') + setModalFeedbackMode('free') document.body.style.cursor = 'grabbing' const onMove = (pointerEvent: PointerEvent) => { - const hit = makeRay(pointerEvent.clientX, pointerEvent.clientY).intersectPlane( + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + const hit = makeRay(effectivePointer.x, effectivePointer.y).intersectPlane( rotationPlane, new Vector3(), ) @@ -1754,7 +2523,9 @@ function BlockEditor({ let angle = accumulatedAngle const snapping = !pointerEvent.altKey && isAngleSnapActive() if (snapping) { - const step = (ROTATION_SNAP_ANGLE_DEGREES * Math.PI) / 180 + const step = + (blockPrecisionSnapStep(ROTATION_SNAP_ANGLE_DEGREES, pointerEvent.shiftKey) * Math.PI) / + 180 angle = Math.round(angle / step) * step } if (snapping && Math.abs(angle) > 1e-6 && angle !== lastSnapAngle) { @@ -1763,6 +2534,8 @@ function BlockEditor({ } else if (!snapping) { lastSnapAngle = null } + setTransformNumericInput(blockTransformDisplayValue('rotate', angle)) + setModalFeedbackMode(pointerEvent.shiftKey ? 'precision' : snapping ? 'angle' : 'free') const result = applyBlockCommand(baseTopology, { type: 'rotate-components', selection: baseSelection, @@ -1778,7 +2551,7 @@ function BlockEditor({ latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) } const finish = (commit: boolean) => { @@ -1790,13 +2563,25 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') if (commit && latestTopology && Math.abs(latestAngle) > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) + commitAdjustableOperation( + baseTopology, + { + type: 'rotate-components', + selection: baseSelection, + pivot: origin, + axis: AXIS_VECTORS[axis], + angle: latestAngle, + }, + 'Rotate', + ) playBlockSfx('finish') } else if (!commit) { playBlockSfx('cancel') @@ -1814,7 +2599,17 @@ function BlockEditor({ window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, - [displayTopology, makeRay, node.id, ownsEditSession, selectedIds.length, selection, target], + [ + commitAdjustableOperation, + displayTopology, + makeRay, + node.id, + ownsEditSession, + selectedIds.length, + selection, + target, + sceneApi.markDirty, + ], ) const beginScaleDrag = useCallback( @@ -1840,18 +2635,29 @@ function BlockEditor({ let lastSnapFactor: number | null = null let latestTopology: BlockTopology | null = null let finished = false + let effectivePointer = { x: event.nativeEvent.clientX, y: event.nativeEvent.clientY } + let previousRawPointer = { ...effectivePointer } useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'scale')) playBlockSfx('drag-start') useViewer.getState().setInputDragging(true) setActiveTransform({ operation: 'scale', constraint: axis }) + setTransformNumericInput('1') + setModalFeedbackMode('free') document.body.style.cursor = 'grabbing' const onMove = (pointerEvent: PointerEvent) => { + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } const parameter = closestAxisParameterToRay( worldOrigin, worldAxis, - makeRay(pointerEvent.clientX, pointerEvent.clientY), + makeRay(effectivePointer.x, effectivePointer.y), ) const worldPoint = worldOrigin .clone() @@ -1859,8 +2665,12 @@ function BlockEditor({ const localPoint = target.worldToLocal(worldPoint) const distance = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) const snapStep = - !pointerEvent.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + !pointerEvent.altKey && isGridSnapActive() + ? blockPrecisionSnapStep(useEditor.getState().gridSnapStep, pointerEvent.shiftKey) + : 0 const factor = blockScaleFactorFromDrag(distance, gizmoLength, snapStep) + setTransformNumericInput(blockTransformDisplayValue('scale', factor)) + setModalFeedbackMode(pointerEvent.shiftKey ? 'precision' : snapStep > 0 ? 'grid' : 'free') if (snapStep > 0 && Math.abs(factor - 1) > 1e-6 && factor !== lastSnapFactor) { lastSnapFactor = factor playBlockSfx('resize-step') @@ -1881,7 +2691,7 @@ function BlockEditor({ latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setError(null) } @@ -1894,13 +2704,24 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') if (commit && latestTopology && Math.abs(latestFactor - 1) > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) + commitAdjustableOperation( + baseTopology, + { + type: 'scale-components', + selection: baseSelection, + pivot: origin, + factors: blockScaleFactors(axis, latestFactor), + }, + 'Scale', + ) playBlockSfx('finish') } else if (!commit) { playBlockSfx('cancel') @@ -1920,6 +2741,7 @@ function BlockEditor({ }, [ displayTopology, + commitAdjustableOperation, gizmoLength, makeRay, node.id, @@ -1927,6 +2749,7 @@ function BlockEditor({ selectedIds.length, selection, target, + sceneApi.markDirty, ], ) @@ -1944,18 +2767,36 @@ function BlockEditor({ const initialDistance = Math.max(24, pivotClient.distanceTo(startPointer)) 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: BlockTopology | null = null - let finished = false - - const updatePreview = (clientX: number, clientY: number, altKey: boolean) => { + let activeConstraint: BlockTransformConstraint = 'uniform' + let typedInput = '' + let lastClientX = startPointer.x + let lastClientY = startPointer.y + let lastAltKey = false + let lastShiftKey = false + let effectivePointer = { x: startPointer.x, y: startPointer.y } + let previousRawPointer = { ...effectivePointer } + + const updatePreview = ( + clientX: number, + clientY: number, + altKey: boolean, + precision: boolean, + ) => { + lastClientX = clientX + lastClientY = clientY + lastAltKey = altKey + lastShiftKey = precision const pointer = new Vector2(clientX, clientY) const distance = pointer.distanceTo(pivotClient) - initialDistance - const snapStep = !altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 - const factor = blockScaleFactorFromDrag(distance, initialDistance, snapStep) + const numericValue = blockTransformNumericValue(typedInput, 'scale') + const snapStep = + numericValue === null && !altKey && isGridSnapActive() + ? blockPrecisionSnapStep(useEditor.getState().gridSnapStep, precision) + : 0 + const factor = numericValue ?? blockScaleFactorFromDrag(distance, initialDistance, snapStep) if (snapStep > 0 && Math.abs(factor - 1) > 1e-6 && factor !== lastSnapFactor) { lastSnapFactor = factor playBlockSfx('resize-step') @@ -1966,37 +2807,42 @@ function BlockEditor({ type: 'scale-components', selection: baseSelection, pivot: origin, - factors: blockScaleFactors('uniform', factor), + factors: blockScaleFactorsForConstraint(activeConstraint, factor), }) if (!result.ok) { setError(result.error) return } latestFactor = factor + setTransformNumericInput(typedInput || blockTransformDisplayValue('scale', factor)) + setModalFeedbackMode( + typedInput ? 'exact' : precision ? 'precision' : snapStep > 0 ? 'grid' : 'free', + ) latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setError(null) } - const finish = (commit: boolean) => { - if (finished) return - finished = true - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerdown', onPointerDown, true) - window.removeEventListener('keydown', onKeyDown, true) - window.removeEventListener('contextmenu', onContextMenu, true) - window.removeEventListener('blur', onCancel) - cancelDragRef.current = null + const complete = (commit: boolean) => { useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) - useViewer.getState().setInputDragging(previousInputDragging) - document.body.style.cursor = previousCursor + sceneApi.markDirty(node.id) setPreviewTopology(null) setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') if (commit && latestTopology && Math.abs(latestFactor - 1) > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) + commitAdjustableOperation( + baseTopology, + { + type: 'scale-components', + selection: baseSelection, + pivot: origin, + factors: blockScaleFactorsForConstraint(activeConstraint, latestFactor), + }, + 'Scale', + ) playBlockSfx('finish') } else if (!commit) { playBlockSfx('cancel') @@ -2009,24 +2855,26 @@ function BlockEditor({ const onMove = (pointerEvent: PointerEvent) => { lastPointerClientRef.current = new Vector2(pointerEvent.clientX, pointerEvent.clientY) - updatePreview(pointerEvent.clientX, pointerEvent.clientY, pointerEvent.altKey) + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + updatePreview( + effectivePointer.x, + effectivePointer.y, + pointerEvent.altKey, + pointerEvent.shiftKey, + ) } - const onPointerDown = (pointerEvent: PointerEvent) => { + const onPointerDown = (pointerEvent: PointerEvent, finish: (commit: boolean) => void) => { pointerEvent.preventDefault() pointerEvent.stopImmediatePropagation() - if (pointerEvent.button === 2) { - window.addEventListener( - 'contextmenu', - (event) => { - event.preventDefault() - event.stopImmediatePropagation() - }, - { capture: true, once: true }, - ) - } finish(pointerEvent.button !== 2) } - const onKeyDown = (keyboardEvent: KeyboardEvent) => { + const onKeyDown = (keyboardEvent: KeyboardEvent, finish: (commit: boolean) => void) => { const element = keyboardEvent.target as HTMLElement | null if ( element?.tagName === 'INPUT' || @@ -2034,7 +2882,23 @@ function BlockEditor({ element?.isContentEditable ) return - if (keyboardEvent.key === 'Enter') { + const nextInput = blockTransformNumericInputFromKey(typedInput, keyboardEvent.key) + const constraint = blockTransformConstraintFromKey(keyboardEvent.key, keyboardEvent.shiftKey) + if (constraint) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + activeConstraint = constraint + setActiveTransform({ operation: 'scale', constraint }) + lastSnapFactor = null + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else if (nextInput !== null) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + typedInput = nextInput + setTransformNumericInput(nextInput) + lastSnapFactor = null + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else if (keyboardEvent.key === 'Enter') { keyboardEvent.preventDefault() keyboardEvent.stopImmediatePropagation() finish(true) @@ -2044,29 +2908,26 @@ function BlockEditor({ finish(false) } } - const onContextMenu = (event: Event) => { - event.preventDefault() - event.stopImmediatePropagation() - } - const onCancel = () => finish(false) - useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'scale')) playBlockSfx('drag-start') - useViewer.getState().setInputDragging(true) setTransformTool('transform') setToolbarPanel(null) setActiveTransform({ operation: 'scale', constraint: 'uniform' }) + setTransformNumericInput('') + setModalFeedbackMode('free') setError(null) - document.body.style.cursor = 'nwse-resize' - cancelDragRef.current = onCancel - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerdown', onPointerDown, true) - window.addEventListener('keydown', onKeyDown, true) - window.addEventListener('contextmenu', onContextMenu, true) - window.addEventListener('blur', onCancel, { once: true }) + beginBlockModalSession({ + cancelRef: cancelDragRef, + cursor: 'nwse-resize', + onFinish: complete, + onKeyDown, + onPointerDown, + onPointerMove: onMove, + }) return true }, [ camera, + commitAdjustableOperation, displayTopology, gizmoLength, gl.domElement, @@ -2075,12 +2936,14 @@ function BlockEditor({ selectedIds.length, selection, target, + sceneApi.markDirty, ]) 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 edgeIds = mode === 'edge' && selectedIds.includes(edgeId) ? [...selectedIds] : [edgeId] const baseTopology = displayTopology const startClientX = event.nativeEvent.clientX const startClientY = event.nativeEvent.clientY @@ -2093,14 +2956,17 @@ function BlockEditor({ let latestTopology: BlockTopology | null = null let latestSelection: BlockSelection | null = null let finished = false + let effectivePointer = { x: startClientX, y: startClientY } + let previousRawPointer = { ...effectivePointer } useBlockEditSession.getState().setSelection(node.id, { mode: 'edge', - ids: [edgeId], + ids: edgeIds, activeId: edgeId, }) setToolbarPanel(null) setError(null) + setBevelWidth(0) useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'bevel')) playBlockSfx('operation-start') useViewer.getState().setInputDragging(true) @@ -2109,8 +2975,8 @@ function BlockEditor({ const updatePreview = (width: number, segments = activeSegments) => { if (width <= 1e-6) return false const result = applyBlockCommand(baseTopology, { - type: 'bevel-edge', - edgeId, + type: 'bevel-edges', + edgeIds, width, segments, profile: 0.5, @@ -2122,18 +2988,26 @@ function BlockEditor({ } activeSegments = segments latestWidth = width + setBevelWidth(width) latestTopology = result.topology latestSelection = result.selection setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setError(null) return true } const onMove = (pointerEvent: PointerEvent) => { - const deltaX = pointerEvent.clientX - startClientX - const deltaY = pointerEvent.clientY - startClientY + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + const deltaX = effectivePointer.x - startClientX + const deltaY = effectivePointer.y - startClientY if (Math.hypot(deltaX, deltaY) < 2) return const width = blockBevelWidthFromDrag(deltaX, deltaY, extent, viewportHeight) const widthStep = Math.floor(width / Math.max(0.01, extent * 0.025)) @@ -2165,16 +3039,23 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.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 }) - useBlockEditSession.getState().setSelection(node.id, { - ...latestSelection, - activeId: latestSelection.ids.at(-1) ?? null, - }) + commitAdjustableOperation( + baseTopology, + { + type: 'bevel-edges', + edgeIds, + width: latestWidth, + segments: activeSegments, + profile: 0.5, + clampOverlap: true, + }, + 'Bevel', + ) playBlockSfx('operation-commit') } else if (!commit) { playBlockSfx('cancel') @@ -2193,7 +3074,18 @@ function BlockEditor({ window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, - [bevelSegments, displayTopology, extent, gl.domElement, node.id, ownsEditSession], + [ + bevelSegments, + commitAdjustableOperation, + displayTopology, + extent, + gl.domElement, + mode, + node.id, + ownsEditSession, + selectedIds, + sceneApi.markDirty, + ], ) const previewLoopCut = useCallback((edgeId: string | null) => { @@ -2282,6 +3174,8 @@ function BlockEditor({ let lastSnapFactor: number | null = null let finished = false let confirmationAttached = false + let effectivePointer = { x: event.nativeEvent.clientX, y: event.nativeEvent.clientY } + let previousRawPointer = { ...effectivePointer } const updatePreview = (factor: number) => { const effectiveFactor = resolveLoopCutSlideFactor(activeCuts, factor) @@ -2303,7 +3197,7 @@ function BlockEditor({ setLoopCutSegments(segments) setLoopCutFactor(effectiveFactor) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setError(null) return true } @@ -2317,10 +3211,17 @@ function BlockEditor({ const onMove = (pointerEvent: PointerEvent) => { if (activeCuts > 1) return + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } const parameter = closestAxisParameterToRay( worldStart, worldAxis, - makeRay(pointerEvent.clientX, pointerEvent.clientY), + makeRay(effectivePointer.x, effectivePointer.y), ) let factor = Math.min( 0.98, @@ -2328,7 +3229,10 @@ function BlockEditor({ ) const snapping = isGridSnapActive() && !pointerEvent.altKey if (snapping) { - const step = useEditor.getState().gridSnapStep + const step = blockPrecisionSnapStep( + useEditor.getState().gridSnapStep, + pointerEvent.shiftKey, + ) if (step > 0) factor = Math.min( 0.98, @@ -2355,7 +3259,7 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) @@ -2363,11 +3267,16 @@ function BlockEditor({ setLoopCutEdgeId(null) setLoopCutSliding(false) if (outcome !== 'cancel' && latestTopology && latestSelection && latestFactor > 0) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) - useBlockEditSession.getState().setSelection(node.id, { - ...latestSelection, - activeId: latestSelection.ids.at(-1) ?? null, - }) + commitAdjustableOperation( + baseTopology, + { + type: 'loop-cut', + edgeId, + factor: latestFactor, + cuts: activeCuts, + }, + 'Loop Cut', + ) playBlockSfx('operation-commit') } else if (outcome === 'cancel') { playBlockSfx('cancel') @@ -2400,9 +3309,42 @@ function BlockEditor({ window.addEventListener('pointerdown', onConfirm, true) }) }, - [loopCutCount, makeRay, node.id, node.topology, ownsEditSession, target], + [ + commitAdjustableOperation, + loopCutCount, + makeRay, + node.id, + node.topology, + ownsEditSession, + target, + sceneApi.markDirty, + ], ) + const beginFaceOperationModal = useBlockFaceOperation({ + camera, + cancelRef: cancelDragRef, + canvas: gl.domElement, + closeToolbar: () => setToolbarPanel(null), + commit: commitAdjustableOperation, + displayTopology, + extent, + lastPointerClientRef, + mode, + nodeId: node.id, + ownsEditSession, + playSfx: playBlockSfx, + sceneApi, + selectedIds, + selection, + setActiveFaceOperation, + setError, + setFaceOperationValue, + setModalFeedbackMode, + setPreviewTopology, + setTransformNumericInput, + target, + }) const commitCommand = (command: BlockCommand, operator: TopologyOperator) => { if (cancelDragRef.current) return useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operator)) @@ -2412,37 +3354,23 @@ function BlockEditor({ setError(result.error) return } - useScene.getState().updateNode(node.id, { topology: result.topology }) - useBlockEditSession.getState().setSelection(node.id, { + sceneApi.update(node.id, { topology: result.topology }) + const session = useBlockEditSession.getState() + session.setSelection(node.id, { ...result.selection, activeId: result.selection.ids.at(-1) ?? null, }) + session.setLastOperation(node.id, null) + setLastOperationPanelOpen(false) setToolbarPanel(null) setError(null) if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) playBlockSfx(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 extrudeSelectedFace = () => beginFaceOperationModal('extrude') - const insetSelectedFace = () => { - if (mode !== 'face' || selectedIds.length !== 1) return - commitCommand( - { - type: 'inset-face', - faceId: selectedIds[0]!, - amount: Number(insetAmount), - depth: 0, - }, - 'inset', - ) - } + const insetSelectedFace = () => beginFaceOperationModal('inset') const deleteSelection = () => { if (selectedIds.length === 0) return @@ -2455,11 +3383,55 @@ function BlockEditor({ } const dissolveSelection = () => { - if (mode !== 'edge' || selectedIds.length !== 1) return - commitCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') + if (mode === 'edge' && selectedIds.length > 0) { + commitCommand({ type: 'dissolve-edges', edgeIds: selectedIds }, 'dissolve') + } else if (mode === 'face' && selectedIds.length > 1) { + commitCommand({ type: 'dissolve-faces', faceIds: selectedIds }, 'dissolve') + } + } + + const adjustLastOperation = (command: BlockCommand) => { + if (!lastOperation || cancelDragRef.current) return + const replacement = replaceCommittedBlockOperation(operationServices, lastOperation, command) + if (!replacement.ok) { + setError(replacement.error) + setLastOperationPanelOpen(false) + return + } + const session = useBlockEditSession.getState() + session.setLastOperation(node.id, replacement.operation) + session.setSelection(node.id, { + ...replacement.operation.resultSelection, + activeId: replacement.operation.resultSelection.ids.at(-1) ?? null, + }) + setError(null) + playBlockSfx('resize-step') + } + + const repeatLastOperation = () => { + if (!lastOperation || cancelDragRef.current) return + const repeated = repeatCommittedBlockOperation(operationServices, lastOperation, { + mode, + ids: selectedIds, + activeId, + }) + if (!repeated.ok) { + setError(repeated.error) + return + } + const session = useBlockEditSession.getState() + session.setLastOperation(node.id, repeated.operation) + session.setSelection(node.id, { + ...repeated.operation.resultSelection, + activeId: repeated.operation.resultSelection.ids.at(-1) ?? null, + }) + setLastOperationPanelOpen(true) + setError(null) + playBlockSfx('operation-commit') } const updateSelection = (next: BlockSelectionState) => { + if (!blockSelectionChanged({ mode, ids: selectedIds, activeId }, next)) return useBlockEditSession.getState().setSelection(node.id, next) setError(null) playBlockSfx('component-select') @@ -2473,6 +3445,7 @@ function BlockEditor({ updateSelection(clearBlockSelection({ mode, ids: selectedIds, activeId })) const keyboardActionsRef = useRef({ + beginKeyboardTransformModal, beginUniformScaleModal, canBevel: mode === 'edge', clearSelection, @@ -2480,12 +3453,16 @@ function BlockEditor({ dissolveSelection, extrudeSelectedFace, hasSelection: selectedIds.length > 0, + hasLastOperation: Boolean(lastOperation), insetSelectedFace, invertSelection, mergeSelection, selectAll, + repeatLastOperation, + showLastOperation: () => setLastOperationPanelOpen(true), }) keyboardActionsRef.current = { + beginKeyboardTransformModal, beginUniformScaleModal, canBevel: mode === 'edge', clearSelection, @@ -2493,10 +3470,13 @@ function BlockEditor({ dissolveSelection, extrudeSelectedFace, hasSelection: selectedIds.length > 0, + hasLastOperation: Boolean(lastOperation), insetSelectedFace, invertSelection, mergeSelection, selectAll, + repeatLastOperation, + showLastOperation: () => setLastOperationPanelOpen(true), } useEffect(() => { @@ -2513,7 +3493,10 @@ function BlockEditor({ const key = event.key.toLowerCase() const actions = keyboardActionsRef.current let handled = true - if (key === 'b' && (event.ctrlKey || event.metaKey)) { + if (event.key === 'F9') { + if (actions.hasLastOperation) actions.showLastOperation() + else handled = false + } else if (key === 'b' && (event.ctrlKey || event.metaKey)) { if (actions.canBevel) { playBlockSfx('tool-select') setBevelSegments(DEFAULT_BEVEL_SEGMENTS) @@ -2526,22 +3509,21 @@ function BlockEditor({ } else if (key === 'i' && (event.ctrlKey || event.metaKey)) { actions.invertSelection() } else if (key === 'g') { - if (actions.hasSelection) { - playBlockSfx('tool-select') - setTransformTool('transform') - } + if (actions.hasSelection) actions.beginKeyboardTransformModal('translate') } else if (key === 'e') { actions.extrudeSelectedFace() } else if (key === 'i') { actions.insetSelectedFace() + } else if (key === 'r' && event.shiftKey) { + if (actions.hasLastOperation) actions.repeatLastOperation() + else handled = false } else if (key === 'r') { if (event.ctrlKey || event.metaKey) { playBlockSfx('tool-select') setTransformTool('loop-cut') setToolbarPanel(null) } else if (actions.hasSelection) { - playBlockSfx('tool-select') - setTransformTool('transform') + actions.beginKeyboardTransformModal('rotate') } } else if (key === 's') { if (actions.hasSelection) { @@ -2554,7 +3536,7 @@ function BlockEditor({ actions.mergeSelection() } else if (key === 'd') { actions.dissolveSelection() - } else if (event.key === 'Delete' || event.key === 'Backspace' || key === 'x') { + } else if (event.key === 'Delete' || key === 'x') { actions.deleteSelection() } else { handled = false @@ -2576,7 +3558,7 @@ function BlockEditor({ const deleteNode = (event: ReactMouseEvent) => { event.stopPropagation() useViewer.getState().setSelection({ selectedIds: [] }) - useScene.getState().deleteNode(node.id) + sceneApi.delete(node.id) playBlockSfx('delete') } @@ -2584,14 +3566,23 @@ function BlockEditor({ const operationAvailability = blockOperationAvailability(mode, selectedIds.length) const loopCutActive = transformTool === 'loop-cut' const bevelActive = transformTool === 'bevel' - const componentStatus = blockComponentStatus({ - mode, - selectedCount: selectedIds.length, - tool: transformTool, - loopCutCount, - loopCutFactor, - bevelSegments, - }) + const componentStatus = activeFaceOperation + ? blockModalFaceOperationStatus( + activeFaceOperation, + faceOperationValue || '0', + modalFeedbackMode, + ) + : activeTransform + ? blockModalTransformStatus(activeTransform, transformNumericInput, modalFeedbackMode) + : blockComponentStatus({ + mode, + selectedCount: selectedIds.length, + tool: transformTool, + loopCutCount, + loopCutFactor, + bevelSegments, + bevelWidth, + }) return ( @@ -2665,42 +3656,35 @@ function BlockEditor({ {(['x', 'y', 'z'] as const).map((axis) => ( ))} {(Object.keys(PLANE_NORMAL) as PlaneAxes[]).map((plane) => ( ))} {(['x', 'y', 'z'] as const).map((axis) => ( ))} @@ -2805,50 +3789,32 @@ function BlockEditor({
setExtrudeDistance(event.target.value)} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - extrudeSelectedFace() - }} - step="0.05" - type="number" - value={extrudeDistance} - /> - } + disabled={selectedIds.length === 0} + label="Move selection" + onClick={() => beginKeyboardTransformModal('translate')} + shortcut="G" + > + + + beginKeyboardTransformModal('rotate')} + shortcut="R" + > + + + 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" + label="Inset selected faces" onClick={insetSelectedFace} shortcut="I" > @@ -2892,7 +3858,7 @@ function BlockEditor({ @@ -2901,7 +3867,7 @@ function BlockEditor({ { playBlockSfx('tool-select') setBevelSegments(DEFAULT_BEVEL_SEGMENTS) @@ -2917,6 +3883,16 @@ function BlockEditor({ ) : null}
+ {lastOperation ? ( + setLastOperationPanelOpen((open) => !open)} + > + + + ) : null} + @@ -3000,19 +3976,34 @@ function BlockEditor({ ) : null}
+ {editing && lastOperation && lastOperationPanelOpen ? ( + [size.width / 2, size.height / 2]} + fullscreen + style={{ pointerEvents: 'none' }} + zIndexRange={[80, 0]} + > + setLastOperationPanelOpen(false)} + onRepeat={repeatLastOperation} + operation={lastOperation} + /> + + ) : null} ) } -const BlockSelectionAffordance = () => { - 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 === 'block' ? (selected as BlockNode) : null - }) +const BlockSelectionAffordance = ({ + historyApi, + node, + readOnly, + sceneApi, +}: SelectionAffordanceProps) => { + const blockNode = node.type === 'block' ? node : null const [target, setTarget] = useState(null) - const nodeId = node?.id ?? null + const nodeId = blockNode?.id ?? null const scopeAllowsAffordance = useInteractionScope( (state) => state.scope.kind === 'idle' || @@ -3026,7 +4017,7 @@ const BlockSelectionAffordance = () => { } let frameId = 0 const resolve = () => { - const next = sceneRegistry.nodes.get(nodeId as AnyNodeId) ?? null + const next = sceneRegistry.nodes.get(nodeId) ?? null setTarget((current) => (current === next ? current : next)) if (!next) frameId = window.requestAnimationFrame(resolve) } @@ -3034,10 +4025,17 @@ const BlockSelectionAffordance = () => { return () => window.cancelAnimationFrame(frameId) }, [nodeId]) - if (!node || !target || !scopeAllowsAffordance) return null + if (!blockNode || !target || !scopeAllowsAffordance) return null const mount = target.parent ?? target return createPortal( - , + , mount, undefined, ) diff --git a/packages/nodes/src/block/toolbar-state.test.ts b/packages/nodes/src/block/toolbar-state.test.ts index 0d4e79e679..ed3d21bb3c 100644 --- a/packages/nodes/src/block/toolbar-state.test.ts +++ b/packages/nodes/src/block/toolbar-state.test.ts @@ -11,7 +11,7 @@ import { } from './toolbar-state' describe('block toolbar state', () => { - test('enables face operations only for one selected face', () => { + test('enables face operations for one or more selected faces', () => { expect(blockOperationAvailability('face', 1)).toEqual({ extrude: true, inset: true, @@ -19,7 +19,7 @@ describe('block toolbar state', () => { dissolve: false, bevel: false, }) - expect(blockOperationAvailability('face', 2).extrude).toBe(false) + expect(blockOperationAvailability('face', 2)).toMatchObject({ extrude: true, inset: true }) }) test('enables component-specific vertex and edge operations', () => { @@ -29,6 +29,8 @@ describe('block toolbar state', () => { dissolve: true, bevel: true, }) + expect(blockOperationAvailability('edge', 2).dissolve).toBe(true) + expect(blockOperationAvailability('face', 2).dissolve).toBe(true) expect(blockOperationAvailability('edge', 0).bevel).toBe(true) }) @@ -47,10 +49,25 @@ describe('block toolbar state', () => { loopCutCount: 1, loopCutFactor: 0.5, bevelSegments: 6, + bevelWidth: 0, }), ).toBeNull() }) + test('shows live bevel width and segment count', () => { + expect( + blockComponentStatus({ + mode: 'edge', + selectedCount: 1, + tool: 'bevel', + loopCutCount: 1, + loopCutFactor: 0.5, + bevelSegments: 6, + bevelWidth: 0.2, + }), + ).toBe('Bevel · width 0.2 m · 6 segments · drag changes width · wheel changes segments') + }) + test('builds uniform and axis-specific scale factors', () => { expect(blockScaleFactors('uniform', 1.5)).toEqual([1.5, 1.5, 1.5]) expect(blockScaleFactors('x', 1.5)).toEqual([1.5, 1, 1]) diff --git a/packages/nodes/src/block/toolbar-state.ts b/packages/nodes/src/block/toolbar-state.ts index c5737399d4..adecf81b33 100644 --- a/packages/nodes/src/block/toolbar-state.ts +++ b/packages/nodes/src/block/toolbar-state.ts @@ -31,10 +31,10 @@ export function blockOperationAvailability( selectedCount: number, ): BlockOperationAvailability { return { - extrude: mode === 'face' && selectedCount === 1, - inset: mode === 'face' && selectedCount === 1, + extrude: mode === 'face' && selectedCount >= 1, + inset: mode === 'face' && selectedCount >= 1, merge: mode === 'vertex' && selectedCount >= 2, - dissolve: mode === 'edge' && selectedCount === 1, + dissolve: (mode === 'edge' && selectedCount >= 1) || (mode === 'face' && selectedCount >= 2), bevel: mode === 'edge', } } @@ -51,6 +51,7 @@ export function blockComponentStatus({ loopCutCount, loopCutFactor, bevelSegments, + bevelWidth, }: { mode: BlockToolbarMode selectedCount: number @@ -58,12 +59,14 @@ export function blockComponentStatus({ loopCutCount: number loopCutFactor: number bevelSegments: number + bevelWidth: 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` + const width = String(Math.round(bevelWidth * 1000) / 1000) + return `Bevel · width ${width} m · ${bevelSegments} segments · drag changes width · wheel changes segments` } return selectedCount === 0 ? `Click a ${mode} to select it` : null } diff --git a/packages/nodes/src/block/use-block-face-operation.ts b/packages/nodes/src/block/use-block-face-operation.ts new file mode 100644 index 0000000000..e0d810b139 --- /dev/null +++ b/packages/nodes/src/block/use-block-face-operation.ts @@ -0,0 +1,290 @@ +import { + type AnyNodeId, + type BlockTopology, + type SceneApi, + useLiveNodeOverrides, +} from '@pascal-app/core' +import { + isGridSnapActive, + meshEditScope, + swallowNextClick, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from 'react' +import type { Camera, Object3D } from 'three' +import { Vector2 } from 'three' +import { applyBlockCommand, type BlockCommand, type BlockSelection } from './commands' +import type { BlockSfxAction } from './interaction-sfx' +import { + type BlockModalFaceOperation, + blockFaceOperationCommand, + blockFaceOperationValueFromPointer, +} from './modal-face-operation' +import { beginBlockModalSession } from './modal-session' +import { + type BlockModalFeedbackMode, + blockAccumulatePrecisionPointer, + blockPrecisionSnapStep, + blockTransformNumericInputFromKey, + blockTransformNumericValue, +} from './modal-transform' +import { blockLocalPointToClient, blockSelectionCentroid } from './selection-geometry' + +type StateSetter = Dispatch> + +export type UseBlockFaceOperationOptions = { + camera: Camera + cancelRef: MutableRefObject<(() => void) | null> + canvas: HTMLCanvasElement + closeToolbar: () => void + commit: (baseTopology: BlockTopology, command: BlockCommand, label: string) => boolean + displayTopology: BlockTopology + extent: number + lastPointerClientRef: MutableRefObject + mode: BlockSelection['mode'] + nodeId: AnyNodeId + ownsEditSession: () => boolean + playSfx: (action: BlockSfxAction) => void + sceneApi: Pick + selectedIds: string[] + selection: BlockSelection + setActiveFaceOperation: StateSetter + setError: StateSetter + setFaceOperationValue: StateSetter + setModalFeedbackMode: StateSetter + setPreviewTopology: StateSetter + setTransformNumericInput: StateSetter + target: Object3D +} + +export function useBlockFaceOperation({ + camera, + cancelRef, + canvas, + closeToolbar, + commit, + displayTopology, + extent, + lastPointerClientRef, + mode, + nodeId, + ownsEditSession, + playSfx, + sceneApi, + selectedIds, + selection, + setActiveFaceOperation, + setError, + setFaceOperationValue, + setModalFeedbackMode, + setPreviewTopology, + setTransformNumericInput, + target, +}: UseBlockFaceOperationOptions) { + return useCallback( + (operation: BlockModalFaceOperation) => { + if (!ownsEditSession() || mode !== 'face' || selectedIds.length === 0 || cancelRef.current) { + return false + } + const faceIds = [...selectedIds] + if (faceIds.some((id) => !displayTopology.faces.some((face) => face.id === id))) return false + const origin = blockSelectionCentroid(displayTopology, selection) + if (!origin) return false + const pivotClient = blockLocalPointToClient(origin, target, camera, canvas) + if (!pivotClient) return false + + const startPointer = + lastPointerClientRef.current?.clone() ?? pivotClient.clone().add(new Vector2(80, 0)) + const baseTopology = displayTopology + let latestTopology: BlockTopology | null = null + let latestSelection: BlockSelection | null = null + let latestValue = 0 + let typedInput = '' + let lastClientX = startPointer.x + let lastClientY = startPointer.y + let lastAltKey = false + let lastShiftKey = false + let lastSnapValue: number | null = null + let effectivePointer = { x: startPointer.x, y: startPointer.y } + let previousRawPointer = { ...effectivePointer } + + const updatePreview = ( + clientX: number, + clientY: number, + altKey: boolean, + precision: boolean, + ) => { + lastClientX = clientX + lastClientY = clientY + lastAltKey = altKey + lastShiftKey = precision + const typedValue = blockTransformNumericValue( + typedInput, + operation === 'extrude' ? 'translate' : 'scale', + ) + let value = + typedValue ?? + blockFaceOperationValueFromPointer( + operation, + clientX - startPointer.x, + clientY - startPointer.y, + extent, + ) + const snapping = + operation === 'extrude' && typedValue === null && isGridSnapActive() && !altKey + if (snapping) { + const step = blockPrecisionSnapStep(useEditor.getState().gridSnapStep, precision) + if (step > 0) value = Math.round(value / step) * step + } + setFaceOperationValue(typedInput || String(Math.round(value * 1000) / 1000)) + setModalFeedbackMode( + typedInput ? 'exact' : precision ? 'precision' : snapping ? 'grid' : 'free', + ) + if (Math.abs(value) <= 1e-6) { + latestTopology = null + latestSelection = null + latestValue = 0 + setPreviewTopology(null) + useLiveNodeOverrides.getState().clear(nodeId) + sceneApi.markDirty(nodeId) + return + } + if (snapping && value !== lastSnapValue) { + lastSnapValue = value + playSfx('move-step') + } else if (!snapping) { + lastSnapValue = null + } + const result = applyBlockCommand( + baseTopology, + blockFaceOperationCommand(operation, faceIds, value), + ) + if (!result.ok) { + setError(result.error) + return + } + latestTopology = result.topology + latestSelection = result.selection + latestValue = value + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(nodeId, { topology: result.topology }) + sceneApi.markDirty(nodeId) + setError(null) + } + + const complete = (commitOperation: boolean) => { + useLiveNodeOverrides.getState().clear(nodeId) + sceneApi.markDirty(nodeId) + setPreviewTopology(null) + setActiveFaceOperation(null) + setFaceOperationValue('') + setTransformNumericInput('') + setModalFeedbackMode('free') + if (commitOperation && latestTopology && latestSelection && Math.abs(latestValue) > 1e-6) { + commit( + baseTopology, + blockFaceOperationCommand(operation, faceIds, latestValue), + operation === 'extrude' ? 'Extrude' : 'Inset', + ) + playSfx('operation-commit') + } else if (!commitOperation) { + playSfx('cancel') + } + if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(nodeId)) + swallowNextClick() + } + + const onMove = (pointerEvent: PointerEvent) => { + lastPointerClientRef.current = new Vector2(pointerEvent.clientX, pointerEvent.clientY) + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + updatePreview( + effectivePointer.x, + effectivePointer.y, + pointerEvent.altKey, + pointerEvent.shiftKey, + ) + } + const onPointerDown = (pointerEvent: PointerEvent, finish: (commit: boolean) => void) => { + if (pointerEvent.button !== 0 && pointerEvent.button !== 2) return + pointerEvent.preventDefault() + pointerEvent.stopImmediatePropagation() + finish(pointerEvent.button === 0) + } + const onKeyDown = (keyboardEvent: KeyboardEvent, finish: (commit: boolean) => void) => { + const element = keyboardEvent.target as HTMLElement | null + if ( + element?.tagName === 'INPUT' || + element?.tagName === 'TEXTAREA' || + element?.isContentEditable + ) { + return + } + const nextInput = blockTransformNumericInputFromKey(typedInput, keyboardEvent.key) + if (nextInput !== null) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + typedInput = nextInput + setTransformNumericInput(nextInput) + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else if (keyboardEvent.key === 'Enter') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(true) + } else if (keyboardEvent.key === 'Escape') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(false) + } + } + + useInteractionScope.getState().begin(meshEditScope(nodeId, 'operating', operation)) + playSfx('operation-start') + closeToolbar() + setActiveFaceOperation(operation) + setFaceOperationValue('0') + setTransformNumericInput('') + setModalFeedbackMode('free') + setError(null) + beginBlockModalSession({ + cancelRef, + cursor: operation === 'extrude' ? 'ns-resize' : 'nwse-resize', + onFinish: complete, + onKeyDown, + onPointerDown, + onPointerMove: onMove, + }) + return true + }, + [ + camera, + cancelRef, + canvas, + closeToolbar, + commit, + displayTopology, + extent, + lastPointerClientRef, + mode, + nodeId, + ownsEditSession, + playSfx, + sceneApi, + selectedIds, + selection, + setActiveFaceOperation, + setError, + setFaceOperationValue, + setModalFeedbackMode, + setPreviewTopology, + setTransformNumericInput, + target, + ], + ) +} diff --git a/wiki/architecture/materials-and-themes.md b/wiki/architecture/materials-and-themes.md index 2f4f5d0d10..18856ba9fe 100644 --- a/wiki/architecture/materials-and-themes.md +++ b/wiki/architecture/materials-and-themes.md @@ -64,11 +64,13 @@ Each of these reads `shading`/`textures`/`colorPreset`/`sceneTheme` from `useVie ## Custom-mesh face materials -blockes use the reusable `MaterialRef` model through stable, user-named object slots. `BlockNode.slots` maps slot IDs to `scene:` or `library:` references, `slotNames` stores their editable labels, and each `BlockFace.materialSlot` stores one slot ID. `body` is the permanent base slot and the fallback for unbound or unresolved slots. +Blocks use the reusable `MaterialRef` model through stable, user-named object slots. `BlockNode.slots` maps slot IDs to `scene:` or `library:` references, `slotNames` stores their editable labels, and each `BlockFace.materialSlot` stores one slot ID. `body` is the permanent base slot and the fallback for unbound or unresolved 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.blockFaces`. 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 block inspector calls this collection **Slots**. Users add and rename slots independently from their material binding. A compact dropdown changes the active slot's material using deduplicated `MaterialRef`s already used by scene node slots plus reusable scene-material datablocks. Choosing a 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 slot from transient component selection. +The block inspector calls this collection **Slots**. Users can rename slots and change a slot's material through a compact dropdown using deduplicated `MaterialRef`s already used by scene node slots plus reusable scene-material datablocks. While one or more faces are selected in edit mode, clicking a slot binds those faces to it immediately; there is no separate Assign / Select / Deselect button row. + +Adding a slot while faces are selected creates the slot, binds those faces to it, and assigns a distinct generated accent material in the same scene update. This makes the new surface visibly different in both edit mode and the rendered model before the user chooses a final paint material. With no selected faces, Add Slot is a no-op so it cannot create an invisible, unused slot. Deleting a non-body slot remaps every assigned face to `body` in the same node update, and `body` becomes the active assignment source. The reusable scene or library material remains available to other nodes. diff --git a/wiki/blender-edit-mode-research.md b/wiki/blender-edit-mode-research.md deleted file mode 100644 index f3a496b349..0000000000 --- a/wiki/blender-edit-mode-research.md +++ /dev/null @@ -1,406 +0,0 @@ -# Blender-Style Block 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 block 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 block 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 block 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 | [`BlockNode`](../packages/core/src/schema/nodes/block.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 | `inspectBlockTopology` 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/block/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/block/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/block/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/block/selection.tsx) mounts only for a sole selected block. 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 block; 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: BlockTopology; - 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: BlockNodeId; - 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/block-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 block 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 `applyBlockCommand` model instead of embedding algorithms in `selection.tsx`: - -```ts -type MeshCommand

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

= { - command: MeshCommand

; - baseTopology: BlockTopology; - 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/block.ts` | -| Kind-specific pure adjacency and command kernel | `packages/nodes/src/block/`, kept independent of React/stores/Three | -| Derived Three.js geometry and triangle-to-face metadata | `packages/nodes/src/block/geometry.ts` through `def.geometry` | -| Kind-owned selection/edit contribution | `packages/nodes/src/block/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 block, 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 deleted file mode 100644 index 41198e0f4d..0000000000 --- a/wiki/blender-loop-cut-research.md +++ /dev/null @@ -1,165 +0,0 @@ -# Blender Loop Cut and Slide Research - -## Purpose and conclusion - -This brief records Blender's Loop Cut and Slide behavior for the Pascal block 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 `BlockTopology` 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 block 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 deleted file mode 100644 index 033e5eba9b..0000000000 --- a/wiki/blender-material-assignment-research.md +++ /dev/null @@ -1,177 +0,0 @@ -# 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 block 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: `BlockFace.materialSlot` identifies an object-local slot, `BlockNode.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 blockes 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 BlockFace = { - id: string; - vertexIds: string[]; - materialSlot: string; -}; - -type BlockNode = { - topology: { faces: BlockFace[] /* ... */ }; - slots?: Record; -}; -``` - -The initial block keeps every face on `materialSlot: "body"`. Its unbound `body` slot resolves to the shared wall-role default color. Adding another material creates a new stable slot ID and maps it to a reusable `MaterialRef`; assigning faces changes only their `materialSlot` string. - -The current schema already has this shape in [`block.ts`](../packages/core/src/schema/nodes/block.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 block Edit Mode, primarily in Face selection mode. -2. Active slot/material preview showing only slots already used by the block, 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 block commands already operate on stable face identities. - -## MVP acceptance matrix - -| Case | Expected result | -| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | -| New block | 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 block 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. diff --git a/wiki/floorplan-chapter-17-assessment.md b/wiki/floorplan-chapter-17-assessment.md deleted file mode 100644 index c9683ac366..0000000000 --- a/wiki/floorplan-chapter-17-assessment.md +++ /dev/null @@ -1,196 +0,0 @@ -# Floor Plan Chapter 17 Assessment - -## Purpose - -This document compares the guidance in `Chapter_17_Floor_Plan_Dimensions_and_Notes.pdf` with Pascal's current floor-plan implementation. It records what the chapter teaches, what the editor supports, and the product's intentional scope boundaries. - -The review covered the full 19-page chapter and the floor-plan stack across: - -- Core floor-plan, wall, opening, and measurement schemas. -- The registry-owned `FloorplanGeometry` contract. -- Editor 2D rendering and interaction layers. -- Node-specific floor-plan builders. -- Automatic wall and opening dimension planning. -- Persistent measurements and smart measurement. -- Door/window documentation and schedules. -- Per-level PDF export. - -## What the chapter is teaching - -The chapter is primarily about construction communication, not merely measuring geometry. Its main principles are: - -1. A drawing must locate and size every construction-critical feature without requiring field workers to guess, scale the drawing, or perform unnecessary arithmetic. -2. Dimensions must be organized into consistent strings that remain readable and uncrowded. -3. The selected datum must match the construction method: centerline, face of stud, face of finish, masonry opening, rough opening, or another explicit reference. -4. Dimension graphics must follow a consistent standard: thin lines, extension-line gaps, extension-line overshoot, uniform terminators, readable aligned text, and predictable spacing. -5. Exterior strings normally progress from detailed opening/partition information to the overall building dimension. -6. Local or specific notes identify individual features through leaders. General notes apply to the whole drawing and are normally numbered in a dedicated sheet area. -7. Door/window schedules and feature notes may replace repeated dimensions when they communicate the information more clearly. -8. Drawing scale, paper-space text size, line weight, and sheet composition are part of the construction-document contract. -9. Curved, circular, masonry, concrete, and foundation-related construction require different dimension semantics from ordinary wood-frame walls. - -## Current implementation - -### Automatic construction dimensions - -`packages/nodes/src/wall/construction-dimensions.ts` already produces coordinated level-wide construction dimensions. The exterior hierarchy includes: - -1. Opening widths. -2. Door and window center locations. -3. Intersecting partition references. -4. Structural columns. -5. Facade jogs, projections, and recesses. -6. Overall facade dimensions. -7. A structural overall dimension when an exterior column row extends beyond the wall envelope. - -The planner also supports: - -- Collinear wall runs that form one facade. -- Disconnected facade runs. -- Angled exterior walls. -- Exterior-side classification. -- Wall-thickness-aware partition references. -- Interior partition strings, including geometrically enclosed partitions whose side metadata remains stale after wall splitting. -- Subdivision chains on every exterior orientation when internal walls divide a facade into multiple runs. -- Hosted door and window widths. -- Interior clear spans bounded by adjacent wall faces. -- Suppression of very short accidental segments. -- Associative updates when the contributing model geometry changes. - -`packages/nodes/src/wall/floorplan.ts` integrates these dimensions into the registry-driven wall floor-plan builder. - -### Dimension graphics - -`packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx` implements several conventions from the chapter: - -- Aligned dimension lines. -- A gap between the feature and extension line. -- Extension lines that pass beyond the dimension line. -- Consistent 45-degree architectural slash terminators. -- Thin dimension and extension lines. -- Text above the dimension line. -- Text that remains readable when the plan is rotated. -- Explicit aligned baselines for stepped facade dimensions. -- Separate edit and document presentation profiles. -- True modeled wall thickness in document output while retaining interactive legibility in edit mode. -- Paper-space dimension text, tick, extension-gap, overshoot, and label-offset sizing in PDF output. -- Whole-millimetre document notation without an `mm` suffix, while retaining metre notation in the interactive editor. -- Short-segment values outside the dimension ticks when the value cannot fit inside. - -### Automatic annotation layout - -`packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts` now resolves automatic dimension-value collisions in both the live floor plan and PDF composition. It supports: - -- Label-to-label separation, including dense clusters. -- Stable same-string drawing order and priority for farther-out architectural strings. -- Movement along the dimension string before crossing into an adjacent tier. -- Fixed door/window mark pills as obstacles. -- Semantic architectural obstacles for walls, wall corners, door symbols and swing envelopes, windows, and columns. -- Sampled diagonal wall outlines, avoiding the oversized screen-aligned bounds produced by rotated walls. -- Outside-end placement for short values, followed by outside-start when the end side is blocked. -- Matching baseline extensions when a short value changes sides. -- A leader and true tick-to-tick baseline when both outside positions require further relocation. - -The former orange/red dashed collision overlay was removed because it displayed stale pre-layout conflicts on top of labels that the automatic resolver had already made readable. - -`packages/nodes/src/shared/construction-length.ts` formats imperial construction dimensions using feet, inches, and reduced fractions rounded to the nearest sixteenth. - -### Persistent measurements - -The existing measurement system is broader than the chapter's drafting examples. It supports: - -- Distance. -- Angle. -- Area. -- Perimeter. -- Prism volume. -- Free and associative semantic anchors. -- Wall, roof, slab, ceiling, zone, and site features. -- Live updates when referenced geometry changes. -- Dangling-reference presentation and explicit detach behavior. -- 2D and 3D drafting and editing. -- Smart transient measurement reports. - -The architecture is documented in `wiki/architecture/measurements.md`. These measurements remain analysis annotations rather than architectural construction-dimension strings. - -### Manual construction dimensions - -The editor provides a dedicated associative `ConstructionDimensionNode` for architectural drafting in Expert mode. A drafter can: - -- Pick stable semantic references or free points. -- Create point-to-point, continuous, radius, diameter, center-mark, chord, arc-length, angular, and coordinate dimensions. -- Place and later move the dimension baseline. -- Reposition individual witness references. -- Suppress or restore individual segments. -- Keep dimensions associated with their host geometry as walls, openings, and other supported elements change. - -Manual construction dimensions render in the live floor plan and PDF output, and their visibility is controlled independently from automatic dimensions and analysis measurements. - -### Door and window documentation - -`packages/nodes/src/shared/opening-documentation.ts` provides: - -- Deterministic automatic door and window marks. -- Explicit mark overrides. -- Duplicate explicit-mark warnings. -- Mark bubbles and leaders. -- Door schedules. -- Window schedules. -- Nominal dimensions. -- Optional verified rough-opening dimensions. -- Window sill and head heights. -- Door operation, frame, and hardware fields. - -The rough-opening fields intentionally remain optional rather than being invented from the nominal modeled opening size. - -### Rooms, stairs, and other plan graphics - -- Architectural room zones provide room names and numbers, finish and occupancy metadata, ceiling heights, clear dimensions, and room schedules. Generic colored zones remain available for non-room uses. -- Stairs render footprints, treads, and direction arrows, but do not yet emit a complete construction stair note. -- Columns can contribute structural center references to automatic exterior strings. -- The generic floor-plan registry already renders walls, doors, windows, slabs, ceilings, zones, roofs, stairs, columns, furniture, MEP nodes, and annotation nodes through a common geometry contract. - -### PDF export - -`packages/editor/src/lib/floorplan/floorplan-export.tsx` currently provides: - -- Per-level PDF plan pages. -- North-up orientation that accounts for building rotation. -- Full and structure-only export scopes. -- Door and window schedule pages. -- Registry-driven geometry matching the live floor-plan builders. -- Conversion of non-scaling SVG strokes for PDF output. -- Preservation of persistent measurement value labels in full export. -- Respect for the existing measurement-visibility preference. -- Document-purpose wall rendering at modeled thickness. -- Document metric notation and paper-space sizing for dimensions, measurement labels, room labels, annotation text, mark bubbles, and annotation linework. -- The same automatic annotation collision layout used by the live floor plan. - -The export intentionally fits the plan to an A4 landscape page. - -## Intentional scope boundaries - -### Walls use one modeled thickness - -`WallNode` stores one total thickness and finish materials. It does not model separate studs, sheathing, finish layers, veneer, air space, concrete block, or furring. Face-based dimensions therefore reference the modeled wall face rather than a separately proven construction layer. - -### Export uses the supported fitted-page presentation - -PDF export fits each supported plan to an A4 landscape page. Construction dimensions, measurements, annotation text, room labels, mark bubbles, and annotation linework use the existing document presentation profiles. - -### Automatic annotation placement uses its current obstacle set - -Automatic placement handles adjacent labels, short values, opening marks, walls, wall corners, door symbols and swings, windows, columns, and room labels. Drafters can pin a label position, reset it with a double-click, and suppress individual manual-dimension segments. - -## Features that should not be copied blindly - -The chapter was published in 2012. Its example sizes and clearances are useful drafting and design references, but they should not be treated as current building-code requirements. - -Any implementation of hallway, fixture, door, stair, appliance, or room-clearance checks should: - -- Be configurable by jurisdiction and standard profile. -- Be presented as an advisory or verification result unless code provenance is known. -- Avoid embedding manufacturer-dependent rough openings or product sizes as universal facts. -- Avoid silently omitting dimensions merely because a feature is commonly considered standard. - -The product should prefer explicit model data, verified manufacturer data, and user-controlled documentation policies. diff --git a/wiki/lean-to-post-spacing-research.md b/wiki/lean-to-post-spacing-research.md deleted file mode 100644 index 63a71a368c..0000000000 --- a/wiki/lean-to-post-spacing-research.md +++ /dev/null @@ -1,24 +0,0 @@ -# Lean-to post spacing research - -This note records the basis for Pascal's automatic lean-to post layout default. - -## Findings - -- Municipal patio-cover guides treat post spacing as a beam/span design input, not a single universal code value. -- City of La Habra's standard open patio-cover guide includes header tables across post spacings from 6 ft to 20 ft. -- The same La Habra guide warns that rafters spanning more than 8 ft may permanently deflect unless larger lumber is used. -- City of San Diego's patio-cover bulletin defines patio covers as open, one-story accessory structures and ties post sizing to height, while directing custom designs to show framing/foundation details and structural calculations. - -## Product default - -Use 3.0 m, approximately 10 ft, as the automatic target post spacing for generated lean-to extensions. - -This is a visual/planning default, not a structural-code guarantee. It keeps generated spans in the common municipal table range, avoids the clutter of very close spacing, and avoids the heavier-beam implication of wider spacing above roughly 12 ft. - -Automatic generation should always include end posts. Intermediate posts are inserted so no bay is larger than the target spacing. - -## Sources - -- City of La Habra, `Standard Open Patio Cover Requirements`: post-spacing tables include 6 ft, 8 ft, 10 ft, and larger spacings; the guide also warns about objectionable deflection beyond 8 ft rafter spans. https://www.lahabraca.gov/DocumentCenter/View/91/Standard-Open-Patio-Cover-PDF -- City of San Diego, Information Bulletin 206 `Patio Covers`, March 2026: patio covers are open accessory structures, and plans must show framing/foundation details when using custom designs. https://www.sandiego.gov/development-services/forms-publications/information-bulletins/206 -- City of Escondido, `Solid Roof Patio Cover`: beam and post spacing are design-table variables dependent on roof span, roof load, lumber, and footing assumptions. https://www.escondido.gov/DocumentCenter/View/457/8B---Solid-Roof-Patio-Cover-PDF diff --git a/wiki/shed-roof-extension-research.md b/wiki/shed-roof-extension-research.md deleted file mode 100644 index 19426fbb99..0000000000 --- a/wiki/shed-roof-extension-research.md +++ /dev/null @@ -1,128 +0,0 @@ -# Shed / Lean-to Roof Extension Research - -## Scope - -The reference images show an **attached, open-sided lean-to canopy** rather than a new main-roof shape: one roof plane has a high edge at an existing building and a low edge carried by a beam and posts. The examples vary mainly in span and context: - -- a long veranda across a facade; -- a courtyard canopy terminating against a second building; and -- a small entrance canopy with two front posts. - -This distinction matters in Pascal because `RoofSegmentNode` already has a `roofType: 'shed'`. The proposed feature adds attachment, supports, framing, and drainage to that one-plane geometry. - -## Terminology - -- **Shed roof** means a roof with one sloping plane in the [California State University Channel Islands master-plan glossary](https://www.csuci.edu/fs/pdc/documents/csuci2007masterplan.pdf). -- **Mono-pitched roof** is the corresponding UK term: the [City of Edinburgh Council glossary](https://www.edinburgh.gov.uk/housing/improving-edinburgh-neighbourhoods/7) defines it as a roof with one sloping side, usually attached to a wall. [Bury Council](https://www.bury.gov.uk/housing/housing-services/your-home/repairs/alterations-to-your-property/terms-and-conditions) notes that a mono-pitched roof is often called a **lean-to**. -- **Skillion roof** is the common Australian term for the same single-plane form; the [Australian Government's YourHome glossary](https://www.yourhome.gov.au/glossary) lists shed-style and lean-to as aliases. -- **Rafters** are the sloping members carrying a pitched roof; **purlins** run horizontally and support rafters, according to the same [City of Edinburgh glossary](https://www.edinburgh.gov.uk/housing/improving-edinburgh-neighbourhoods/7). Actual light-metal canopy systems may instead place panels across regularly spaced supports, so the editor should treat framing layout as a strategy rather than assume that every assembly contains both rafters and purlins. - -For the product UI, **Lean-to extension** or **Attached canopy** is clearer than just **Shed roof**. It avoids confusion with both a storage shed and Pascal's existing standalone shed-shaped roof segment. - -## Typical assembly and load path - -A conventional attached wood canopy can be represented as this hierarchy: - -1. Roof covering and optional sheathing/deck. -2. Repeated sloping rafters, or a covering-specific support layout. -3. A high-side support at the building: normally a structurally fixed wall ledger, or a separate high beam and posts for a freestanding/independent canopy. -4. A low-side beam at the eave. -5. Repeated posts/columns, with top bracing where required. -6. Post bases and footings carrying loads to the ground. -7. Flashing at building abutments and a gutter/downspout at the low eave. - -The [City of San Diego patio-cover bulletin](https://www.sandiego.gov/development-services/forms-publications/information-bulletins/206) is a useful official example of this system. It requires posts to be anchored at the bottom and braced at the top; describes replacing the building-side beam with a ledger attached to wall studs; says patio rafters must not be supported solely by existing rafter tails or fascia; and warns that existing headers beside openings may need verification. Its prescriptive sizes and fastener schedules are local design rules, not universal Pascal defaults. - -The first-party [Stratco Outback Skillion installation guide](https://www.stratco.com.au/siteassets/pdfs/stratco-outback-skillion-installation-guide15-10-20.pdf) shows the same assembly in a proprietary metal system: columns, low beam, rafters, purlins, high-side back channel, cladding, barge flashing, gutter, and downpipe. It also illustrates purlins placed either between or above rafters and roof sheets turned up at the high end and down toward the gutter. Its 5-degree fall and component dimensions are product-specific examples, not general construction defaults. - -The same bulletin requires custom designs to include framing and foundation plans, sections, connection details, and structural calculations. Pascal should therefore model the geometry and assembly accurately but should not label arbitrary member sizes or spans as structurally compliant unless a jurisdiction/load profile and verified calculation engine are added. - -## Weathering and drainage - -- The high-side wall intersection needs a modeled flashing/abutment condition. [IRC 2015 R903.2.1](https://codes.iccsafe.org/s/IRC2015/chapter-9-roof-assemblies/IRC2015-Pt03-Ch09-SecR903.2.1) requires flashing at wall/roof intersections, roof slope or direction changes, and roof openings, illustrating that this is part of the assembly rather than decorative trim. -- Water should run away from the high-side attachment toward the low eave. The [San Diego bulletin](https://www.sandiego.gov/development-services/forms-publications/information-bulletins/206) uses a minimum slope of 1/4 inch in 12 inches for the patio covers in its scope. -- Minimum slope depends on the selected covering/system. For example, manufacturer specifications list a 2-degree minimum for [LYSAGHT TRIMDEK](https://lysaght.com/profiles/trimdek) and product-dependent 1- or 2-degree minima for [LYSAGHT KLIP-LOK](https://lysaght.com/profiles/klip-lok). The editor should not encode one global minimum as a universal construction rule. -- Gutters belong on the low eave. A side that terminates at another building, as in the courtyard image, also needs a sidewall/end-abutment condition rather than allowing the roof edge to pass through the wall. - -## Current Pascal capabilities and missing semantics - -- [`RoofSegmentNode`](../packages/core/src/schema/nodes/roof-segment.ts) already supports `shed`, footprint width/depth, pitch, wall height, deck and covering thickness, overhang, trim, materials, and hosted roof accessories. Its current shed geometry slopes from local `-Z` (high) to `+Z` (low). -- [`RoofNode`](../packages/core/src/schema/nodes/roof.ts) already groups multiple roof segments and provides roof-level surface materials. -- [`ColumnNode`](../packages/core/src/schema/nodes/column.ts) already provides reusable post/pillar geometry, dimensions, materials, and several braced support styles. -- [`GutterNode`](../packages/core/src/schema/nodes/gutter.ts) already attaches to a roof segment eave and supports outlets that can connect to downspouts. - -What is missing is the semantic relationship that makes these parts one editable extension: a high-side host/attachment, a low-side beam, a governed row of columns, optional exposed framing, flashing, derived elevations, and collision/clearance rules. A wall-less shed segment plus independently placed columns can approximate the pictures visually, but it will drift apart when resized or moved. - -## Implementation shapes to consider - -### 1. Manual composition from existing nodes - -Create a wall-less `shed` roof segment, then place columns and a gutter separately. - -- **Strengths:** smallest implementation and useful as a geometry proof. -- **Limitations:** no ledger/beam/flashing, no shared selection or lifecycle, and resizing the roof does not reliably update posts or drainage. -- **Use:** prototype or short-lived MVP, not the durable model. - -### 2. New composite `lean-to-extension` node (recommended) - -Store the design intent once and derive/render the roof plane, ledger or high beam, low beam, repeated supports, flashing, and optional framing. Reuse existing column and gutter behavior through owned children or well-defined references where independent editing is valuable. - -- **Strengths:** one placement flow, coherent resize/move behavior, works against buildings with any main roof type, and gives room for multiple support/attachment strategies. -- **Tradeoff:** requires a new schema/definition/renderer/system and explicit ownership rules. - -The host should normally be a **wall face or facade interval below the eave**, not the main roof type. Gable, hip, gambrel, mansard, flat, and shed roofs can all accept the same lean-to if their wall/eave geometry provides clearance. Direct attachment to an existing roof plane is a different and more complex join and should be a later explicit attachment mode. - -### 3. Extension fields on every roof segment - -Add post/beam/ledger fields directly to `RoofSegmentNode` and activate them when desired. - -- **Strengths:** reuses the current roof editing surface directly. -- **Limitations:** mixes a main-roof shape with an accessory assembly, leaves many fields inert for ordinary roofs, and makes attachment/ownership harder to express. -- **Use:** only if product semantics intentionally treat every roof segment as a potential complete canopy assembly. - -## Parameters a configurable editor should expose - -### Essential geometry - -- Host and placement: `hostWallId` or facade reference, along-wall offset, span/width, outward projection, and left/right end conditions. -- Vertical geometry: high attachment elevation plus either pitch or low-eave elevation. The third value is derived: `lowEave = highEdge - projection * tan(pitch)`. -- Dependency lock when editing: preserve **high edge**, preserve **low edge**, or preserve **pitch**. This prevents ambiguous resize behavior. -- Plane orientation: downhill direction, local rotation where detached, and alignment/clearance below the host eave. -- Overhangs: low-eave, high-side, and both end overhangs independently; one scalar overhang is insufficient at wall abutments. -- Roof build-up and appearance: deck/panel thickness, covering/material, fascia/edge material, underside/soffit material. - -### Attachment and supports - -- High-side mode: `wall-ledger`/back channel, `independent-high-beam`, and later `reinforced-fascia` or `roof-plane-tie-in`. The first-party [Stratco attached-roof guide](https://www.stratco.com.au/siteassets/pdfs/patios_outback_flat_attached_install.pdf) illustrates wall, reinforced fascia, suspension, and over-roof attachment details, supporting an explicit mode rather than one generic connection. -- Ledger/high-beam dimensions and vertical offset; whether it is visible. -- Low beam dimensions, inset from the drip edge, and material. -- Post layout: count **or** target spacing, left/right setbacks, section/preset, material, and optional bracing. Post heights should derive from beam elevation and the support surface instead of being duplicated free values. -- Support-surface/footing references and a visual footing/post-base option. -- Framing strategy: hidden, rafters, purlin-like supports, or a covering-specific system; member dimensions, spacing, end inset, and material. - -### Weathering - -- High-side apron/counterflashing enabled, projection, and material. -- Left/right termination: open verge, wall abutment/flashing, or joined continuation. -- Low-eave gutter enabled, profile/size, outlets, and downspout positions. Prefer composing the existing gutter/downspout nodes over duplicating their schemas. -- Covering-specific minimum-pitch advisory. Treat warnings as product/jurisdiction guidance, not proof of compliance. - -### Placement and validation - -- Snap the high edge to a valid wall/facade interval, derive the outward normal, and preview the low beam/post row during placement. -- Reject or warn on collisions with the host roof/eave, adjacent buildings, wall openings, and neighboring extensions. -- Warn when a ledger is placed on fascia/rafter tails rather than a valid wall support, following the San Diego bulletin's attachment distinction. -- For a canopy between buildings, resolve both end abutments and drainage explicitly. -- Keep a clear visual distinction between **modeled appearance** and **structurally verified design**. - -## Suggested delivery order - -1. Prove the parametric plane, high/low elevation relationship, host-wall snap, low beam, and governed column row. -2. Add resize/move behavior in both 2D and 3D, with the selected dependency lock. -3. Compose the existing gutter/downspout system and add high-side/side flashing geometry. -4. Add exposed framing strategies and covering-specific advisories. -5. Consider roof-plane tie-ins and structural verification only as separately scoped capabilities. - -## Source-quality note - -The construction sources above are official government guidance, an official model-code publication, and first-party roofing-system specifications. Their numeric requirements are examples tied to a jurisdiction or product. They support the assembly model and validation vocabulary; they should not be copied into Pascal as universal engineering defaults.