diff --git a/src/csg.ts b/src/csg.ts index da0ee79..c8255c4 100644 --- a/src/csg.ts +++ b/src/csg.ts @@ -188,6 +188,33 @@ export function subtractBrush(target: Brush, carver: Brush): Brush[] | null { return mergeBrushListPairs(fragments); } +/** + * Subtract one or more carvers from a target brush. + * + * Returns the convex fragments making up the difference, an empty array when + * the target is completely removed, or null when none of the carvers overlap. + */ +export function differenceBrushes(target: Brush, carvers: readonly Brush[]): Brush[] | null { + let pieces: Brush[] = [target]; + let changed = false; + + for (const carver of carvers) { + const next: Brush[] = []; + for (const piece of pieces) { + const fragments = subtractBrush(piece, carver); + if (fragments === null) next.push(piece); + else { + changed = true; + next.push(...fragments); + } + } + pieces = next; + if (pieces.length === 0) break; + } + + return changed ? pieces : null; +} + /** * Create a hollow shell from a brush by insetting each face inward. * Each face produces one shell piece: the original brush clipped by an diff --git a/src/editor-clip-csg.ts b/src/editor-clip-csg.ts index 35ca298..82cc08b 100644 --- a/src/editor-clip-csg.ts +++ b/src/editor-clip-csg.ts @@ -1,12 +1,12 @@ import { clipBrush, type Brush } from './brush'; import { + differenceBrushes, faceFullyCoveredByOpposingFace, hollowBrush, intersectBrushes, mergeBrushes, roomBrushes, roomThicknessFits, - subtractBrush, } from './csg'; import { vec3Cross, vec3Length, vec3Sub, type Vec3 } from './math'; import { getSelectedBrushItems } from './editor-selection'; @@ -117,19 +117,7 @@ export function csgSubtract(editor: Editor): void { for (const brush of entity.brushes) { if (carverSet.has(brush)) continue; - let pieces: Brush[] = [brush]; - for (const carverBrush of carverSet) { - const next: Brush[] = []; - for (const piece of pieces) { - const fragments = subtractBrush(piece, carverBrush); - if (fragments !== null) { - next.push(...fragments); - } else { - next.push(piece); - } - } - pieces = next; - } + const pieces = differenceBrushes(brush, [...carverSet]) ?? [brush]; newBrushes.push(...pieces); if (pieces.length > 1 || (pieces.length === 1 && pieces[0] !== brush)) { totalFragments += pieces.length; @@ -150,6 +138,42 @@ export function csgSubtract(editor: Editor): void { }); } +/** Replace the first selected brush with its difference from the rest. */ +export function csgDifference(editor: Editor): void { + const brushItems = getSelectedBrushItems(editor); + if (brushItems.length < 2) { + editor.statusMessage = 'CSG Difference: select the target first, then 1+ cutter brushes'; + return; + } + + const target = brushItems[0]; + const carvers = brushItems.slice(1); + const fragments = differenceBrushes(target.brush, carvers.map(item => item.brush)); + if (fragments === null) { + editor.statusMessage = 'CSG Difference: the selected cutters do not intersect the first brush'; + return; + } + + editor.transact('CSG difference', () => { + const carverBrushes = new Set(carvers.map(item => item.brush)); + for (const entity of editor.entities) { + const replacements: Brush[] = []; + for (const brush of entity.brushes) { + if (brush === target.brush) replacements.push(...fragments); + else if (!carverBrushes.has(brush)) replacements.push(brush); + } + entity.brushes = replacements; + } + + editor.reconcileHiddenState(); + editor.selection = fragments.map(brush => ({ type: 'brush', entity: target.entity, brush })); + editor.redrawRequested = true; + editor.statusMessage = fragments.length > 0 + ? `CSG Difference: first brush minus ${carvers.length} cutter${carvers.length === 1 ? '' : 's'} produced ${fragments.length} fragment${fragments.length === 1 ? '' : 's'}` + : `CSG Difference: ${carvers.length === 1 ? 'cutter removed' : 'cutters removed'} the first brush completely`; + }); +} + export function csgHollow(editor: Editor): void { const brushItems = getSelectedBrushItems(editor); if (brushItems.length === 0) { diff --git a/src/editor-commands.ts b/src/editor-commands.ts index 1b288c2..2dc5ef5 100644 --- a/src/editor-commands.ts +++ b/src/editor-commands.ts @@ -1,5 +1,5 @@ import { CommandRegistry, type CommandDefinition, type CommandMenuPlacement } from './commands'; -import { getSelectedPatchItems } from './editor-selection'; +import { getSelectedBrushItems, getSelectedPatchItems } from './editor-selection'; import type { Editor, Tool } from './editor'; import type { Vec3 } from './math'; import { DISPLAY_CATEGORIES, type DisplayCategory, type RendererMode, type TextureFiltering } from './display-policy'; @@ -72,6 +72,8 @@ const menu = (name: string, order: number, group: string, submenu?: string): Com }); const hasSelection = ({ editor }: EditorCommandContext) => editor.selection.length > 0; +const hasSelectedBrushes = ({ editor }: EditorCommandContext) => getSelectedBrushItems(editor).length > 0; +const hasTwoSelectedBrushes = ({ editor }: EditorCommandContext) => getSelectedBrushItems(editor).length >= 2; const hasSelectedFaces = ({ editor }: EditorCommandContext) => editor.selectedFaces.length > 0; const hasSelectedPatches = ({ editor }: EditorCommandContext) => getSelectedPatchItems(editor).length > 0; @@ -370,12 +372,13 @@ function createEditorCommands(): CommandDefinition[] { { id: 'tool.clip', label: 'Clip', defaultShortcut: '4', menu: menu('Tools', 30, 'tools'), checked: ({ editor }) => editor.activeTool === 'clip', execute: ctx => ctx.setTool('clip') }, { id: 'tool.rotate', label: 'Rotate', defaultShortcut: '5', menu: menu('Tools', 40, 'tools'), checked: ({ editor }) => editor.activeTool === 'rotate', execute: ctx => ctx.setTool('rotate') }, { id: 'brush.create-exact', label: 'Create Exact Primitive…', menu: menu('Tools', 50, 'create'), execute: ({ editor }) => openExactPrimitiveDialog(editor) }, - { id: 'csg.subtract', label: 'CSG Subtract', defaultShortcut: 'Mod+Shift+S', menu: menu('CSG', 0, 'csg'), enabled: hasSelection, execute: ({ editor }) => editor.csgSubtract() }, - { id: 'csg.hollow', label: 'Make Hollow', defaultShortcut: 'Mod+Shift+H', menu: menu('CSG', 10, 'csg'), enabled: hasSelection, execute: ({ editor }) => editor.csgHollow() }, - { id: 'csg.room', label: 'Make Room', menu: menu('CSG', 20, 'csg'), enabled: hasSelection, execute: ({ editor }) => editor.csgRoom() }, - { id: 'csg.auto-caulk', label: 'Auto Caulk Selected', menu: menu('CSG', 30, 'csg'), enabled: hasSelection, execute: ({ editor }) => editor.autoCaulkSelected() }, - { id: 'csg.merge', label: 'Merge Brushes', defaultShortcut: 'Mod+Shift+M', menu: menu('CSG', 40, 'csg'), enabled: hasSelection, execute: ({ editor }) => editor.csgMerge() }, - { id: 'csg.intersect', label: 'Intersect Brushes', menu: menu('CSG', 50, 'csg'), enabled: hasSelection, execute: ({ editor }) => editor.csgIntersect() }, + { id: 'csg.subtract', label: 'Carve', description: 'Use the selected brushes to carve intersecting unselected brushes', defaultShortcut: 'Mod+Shift+S', menu: menu('CSG', 0, 'boolean'), enabled: hasSelectedBrushes, execute: ({ editor }) => editor.csgSubtract() }, + { id: 'csg.difference', label: 'Subtract from First', description: 'Subtract every later-selected brush from the first selected brush', menu: menu('CSG', 10, 'boolean'), enabled: hasTwoSelectedBrushes, execute: ({ editor }) => editor.csgDifference() }, + { id: 'csg.intersect', label: 'Intersect', description: 'Replace the selected brushes with their common volume', menu: menu('CSG', 20, 'boolean'), enabled: hasTwoSelectedBrushes, execute: ({ editor }) => editor.csgIntersect() }, + { id: 'csg.merge', label: 'Merge', description: 'Merge the selected brushes into one convex brush', defaultShortcut: 'Mod+Shift+M', menu: menu('CSG', 30, 'boolean'), enabled: hasTwoSelectedBrushes, execute: ({ editor }) => editor.csgMerge() }, + { id: 'csg.hollow', label: 'Hollow', description: 'Turn each selected brush into a hollow shell', defaultShortcut: 'Mod+Shift+H', menu: menu('CSG', 40, 'shell'), enabled: hasSelectedBrushes, execute: ({ editor }) => editor.csgHollow() }, + { id: 'csg.room', label: 'Room', description: 'Turn each selected brush into an inward-facing room', menu: menu('CSG', 50, 'shell'), enabled: hasSelectedBrushes, execute: ({ editor }) => editor.csgRoom() }, + { id: 'csg.auto-caulk', label: 'Auto-Caulk', description: 'Caulk fully covered faces on the selected brushes', menu: menu('CSG', 60, 'materials'), enabled: hasSelectedBrushes, execute: ({ editor }) => editor.autoCaulkSelected() }, ...[1, 2, 4, 8, 16, 32, 64].map((size, index): CommandDefinition => ({ id: `grid.set-${size}`, label: `Grid ${size}`, menu: menu('Grid', index * 10, 'sizes'), checked: ({ editor }) => editor.gridSize === size, execute: ctx => ctx.setGrid(size) })), { id: 'grid.smaller', label: 'Smaller Grid', defaultShortcut: 'BracketLeft', menu: menu('Grid', 80, 'adjust'), execute: ctx => ctx.decreaseGrid() }, { id: 'grid.larger', label: 'Larger Grid', defaultShortcut: 'BracketRight', menu: menu('Grid', 90, 'adjust'), execute: ctx => ctx.increaseGrid() }, diff --git a/src/editor.ts b/src/editor.ts index 0969fa0..e97e9c0 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -250,6 +250,7 @@ import { import { addClipPoint as addEditorClipPoint, cancelClip as cancelEditorClip, + csgDifference as differenceEditorBrushes, csgHollow as hollowEditorBrushes, csgRoom as roomEditorBrushes, autoCaulkSelected as autoCaulkEditorBrushes, @@ -880,6 +881,10 @@ export class Editor { subtractEditorBrushes(this); } + csgDifference(): void { + differenceEditorBrushes(this); + } + csgHollow(): void { hollowEditorBrushes(this); } diff --git a/tests/csg-difference.test.ts b/tests/csg-difference.test.ts new file mode 100644 index 0000000..2809dc4 --- /dev/null +++ b/tests/csg-difference.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'vitest'; +import { createBoxBrush, validateBrush } from '../src/brush'; +import { differenceBrushes } from '../src/csg'; +import { Editor } from '../src/editor'; +import { createEntity } from '../src/entity'; + +describe('CSG difference', () => { + test('subtracts multiple cutters into valid convex fragments', () => { + const target = createBoxBrush([0, 0, 0], [128, 128, 128]); + const firstCutter = createBoxBrush([32, -16, 32], [96, 144, 96]); + const secondCutter = createBoxBrush([-16, 48, 48], [144, 80, 80]); + + const fragments = differenceBrushes(target, [firstCutter, secondCutter]); + + expect(fragments).not.toBeNull(); + expect(fragments!.length).toBeGreaterThan(1); + expect(fragments!.every(fragment => validateBrush(fragment).valid)).toBe(true); + }); + + test('replaces only selected inputs, selects the result, and is undoable', () => { + const editor = new Editor(); + const world = createEntity('worldspawn'); + const target = createBoxBrush([0, 0, 0], [128, 128, 128]); + const cutter = createBoxBrush([32, -16, 32], [96, 144, 96]); + const untouched = createBoxBrush([0, 0, 0], [128, 128, 128], 'base_wall/untouched'); + world.brushes.push(target, cutter, untouched); + editor.entities = [world]; + editor.selection = [ + { type: 'brush', entity: world, brush: target }, + { type: 'brush', entity: world, brush: cutter }, + ]; + + editor.csgDifference(); + + expect(world.brushes).toContain(untouched); + expect(world.brushes).not.toContain(target); + expect(world.brushes).not.toContain(cutter); + expect(editor.selection.length).toBeGreaterThan(1); + expect(editor.selection.every(item => item.type === 'brush' && item.brush !== untouched)).toBe(true); + expect(editor.history.undoLabel).toBe('CSG difference'); + + editor.undo(); + expect(editor.worldspawn.brushes).toHaveLength(3); + }); + + test('keeps inputs and history unchanged when cutters do not intersect', () => { + const editor = new Editor(); + const world = createEntity('worldspawn'); + const target = createBoxBrush([0, 0, 0], [32, 32, 32]); + const cutter = createBoxBrush([64, 64, 64], [96, 96, 96]); + world.brushes.push(target, cutter); + editor.entities = [world]; + editor.selection = [ + { type: 'brush', entity: world, brush: target }, + { type: 'brush', entity: world, brush: cutter }, + ]; + + editor.csgDifference(); + + expect(world.brushes).toEqual([target, cutter]); + expect(editor.selection).toHaveLength(2); + expect(editor.history.canUndo).toBe(false); + expect(editor.statusMessage).toContain('do not intersect'); + }); + + test('allows cutters to remove the target completely', () => { + const editor = new Editor(); + const world = createEntity('worldspawn'); + const target = createBoxBrush([16, 16, 16], [48, 48, 48]); + const cutter = createBoxBrush([0, 0, 0], [64, 64, 64]); + world.brushes.push(target, cutter); + editor.entities = [world]; + editor.selection = [ + { type: 'brush', entity: world, brush: target }, + { type: 'brush', entity: world, brush: cutter }, + ]; + + editor.csgDifference(); + + expect(world.brushes).toEqual([]); + expect(editor.selection).toEqual([]); + expect(editor.history.undoLabel).toBe('CSG difference'); + expect(editor.statusMessage).toContain('removed the first brush completely'); + }); +});