Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/csg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 38 additions & 14 deletions src/editor-clip-csg.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
17 changes: 10 additions & 7 deletions src/editor-commands.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -370,12 +372,13 @@ function createEditorCommands(): CommandDefinition<EditorCommandContext>[] {
{ 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<EditorCommandContext> => ({ 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() },
Expand Down
5 changes: 5 additions & 0 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ import {
import {
addClipPoint as addEditorClipPoint,
cancelClip as cancelEditorClip,
csgDifference as differenceEditorBrushes,
csgHollow as hollowEditorBrushes,
csgRoom as roomEditorBrushes,
autoCaulkSelected as autoCaulkEditorBrushes,
Expand Down Expand Up @@ -880,6 +881,10 @@ export class Editor {
subtractEditorBrushes(this);
}

csgDifference(): void {
differenceEditorBrushes(this);
}

csgHollow(): void {
hollowEditorBrushes(this);
}
Expand Down
85 changes: 85 additions & 0 deletions tests/csg-difference.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading