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
2 changes: 1 addition & 1 deletion e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ test.describe('Modeler: Node operations', () => {
await addShape(page, 'Box');
await page.locator(`text=50\u00d730\u00d750`).click();
await addOp(page, 'Subtract');
await expect(page.locator('text=drop here')).toBeVisible();
await expect(page.locator('text=needs shape')).toBeVisible();
});
});

Expand Down
69 changes: 53 additions & 16 deletions src/components/tree/TreeNode.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useModelerStore } from '../../store/modelerStore';
import { NODE_LABELS, nodeSummary, expectedChildren } from '../../types/operations';
import { NODE_LABELS, nodeSummary, expectedChildren, incompleteNodeIds } from '../../types/operations';
import type { SDFNodeUI } from '../../types/operations';

const KIND_COLORS: Record<string, string> = {
Expand All @@ -20,9 +20,12 @@ interface Props {
node: SDFNodeUI;
depth: number;
isLast?: boolean;
incompleteIds?: Set<string>;
}

export function TreeNode({ node, depth, isLast = true }: Props) {
export function TreeNode({ node, depth, isLast = true, incompleteIds: incompleteIdsProp }: Props) {
// Only subscribe to tree at the root level (when incompleteIdsProp is not provided)
const tree = useModelerStore((s) => incompleteIdsProp ? null : s.tree);
const selectedId = useModelerStore((s) => s.selectedNodeId);
const expandedNodes = useModelerStore((s) => s.expandedNodes);
const selectNode = useModelerStore((s) => s.selectNode);
Expand All @@ -34,11 +37,18 @@ export function TreeNode({ node, depth, isLast = true }: Props) {
const addNodeFromData = useModelerStore((s) => s.addNodeFromData);
const [dragOver, setDragOver] = useState(false);

// Compute incomplete IDs once at the root, pass down to children
const incompleteIds = useMemo(
() => incompleteIdsProp ?? incompleteNodeIds(tree),
[incompleteIdsProp, tree],
);
const isIncomplete = node.enabled && incompleteIds.has(node.id);

const rowRef = useRef<HTMLDivElement>(null);
const isSelected = selectedId === node.id;
const expected = expectedChildren(node.kind);
const hasChildren = node.children.length > 0 || expected > 0;
const isExpanded = expandedNodes.has(node.id);
const isExpanded = expandedNodes.has(node.id) || isIncomplete;
const missingSlots = Math.max(0, expected - node.children.length);
const summary = nodeSummary(node);
const color = KIND_COLORS[node.kind] || '#888';
Expand Down Expand Up @@ -87,8 +97,8 @@ export function TreeNode({ node, depth, isLast = true }: Props) {
className="flex items-center gap-1 pr-1.5 h-[26px] cursor-pointer relative"
style={{
paddingLeft: `${leftPad}px`,
background: isSelected ? 'var(--accent-subtle)' : dragOver ? 'rgba(91,140,223,0.1)' : 'transparent',
borderLeft: isSelected ? `2px solid var(--accent)` : '2px solid transparent',
background: isSelected ? 'var(--accent-subtle)' : dragOver ? 'rgba(91,140,223,0.1)' : isIncomplete ? 'rgba(212,90,90,0.08)' : 'transparent',
borderLeft: isSelected ? `2px solid var(--accent)` : isIncomplete ? '2px solid rgba(212,90,90,0.5)' : '2px solid transparent',
opacity: node.enabled ? 1 : 0.35,
}}
onClick={() => selectNode(node.id)}
Expand Down Expand Up @@ -145,11 +155,22 @@ export function TreeNode({ node, depth, isLast = true }: Props) {
{/* Label */}
<span
className="text-[11px] font-medium truncate shrink-0"
style={{ color: isSelected ? 'var(--text-primary)' : 'var(--text-secondary)' }}
style={{ color: isIncomplete ? '#d45a5a' : isSelected ? 'var(--text-primary)' : 'var(--text-secondary)' }}
>
{NODE_LABELS[node.kind] || node.kind}
</span>

{/* Incomplete warning */}
{isIncomplete && (
<span
className="text-[9px] shrink-0"
style={{ color: '#d45a5a' }}
title={missingSlots > 0 ? `Needs ${missingSlots} more ${missingSlots === 1 ? 'child' : 'children'}` : 'Has incomplete children'}
>
{'\u26A0'}
</span>
)}

{/* Summary */}
<span
className="text-[10px] truncate flex-1 font-mono"
Expand Down Expand Up @@ -197,19 +218,31 @@ export function TreeNode({ node, depth, isLast = true }: Props) {
{hasChildren && isExpanded && (
<div className="relative">
{node.children.map((child, i) => (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
isLast={i === node.children.length - 1 && missingSlots === 0}
/>
child.kind === '_empty' ? (
<PlaceholderSlot
key={child.id}
parentId={node.id}
depth={depth + 1}
isLast={i === node.children.length - 1 && missingSlots === 0}
urgent={isIncomplete}
/>
) : (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
isLast={i === node.children.length - 1 && missingSlots === 0}
incompleteIds={incompleteIds}
/>
)
))}
{Array.from({ length: missingSlots }).map((_, i) => (
<PlaceholderSlot
key={`empty-${i}`}
parentId={node.id}
depth={depth + 1}
isLast={i === missingSlots - 1}
urgent={isIncomplete}
/>
))}
</div>
Expand All @@ -218,7 +251,7 @@ export function TreeNode({ node, depth, isLast = true }: Props) {
);
}

function PlaceholderSlot({ parentId, depth, isLast }: { parentId: string; depth: number; isLast: boolean }) {
function PlaceholderSlot({ parentId, depth, isLast, urgent }: { parentId: string; depth: number; isLast: boolean; urgent?: boolean }) {
const moveNode = useModelerStore((s) => s.moveNode);
const addNodeFromData = useModelerStore((s) => s.addNodeFromData);
const [dragOver, setDragOver] = useState(false);
Expand Down Expand Up @@ -272,9 +305,13 @@ function PlaceholderSlot({ parentId, depth, isLast }: { parentId: string; depth:
>
<span
className="text-[10px] px-1.5 py-0.5 rounded border border-dashed"
style={{ borderColor: dragOver ? 'var(--accent-blue)' : 'var(--border-default)', color: 'var(--text-muted)' }}
style={{
borderColor: dragOver ? 'var(--accent-blue)' : urgent ? 'rgba(212,90,90,0.5)' : 'var(--border-default)',
color: urgent ? '#d45a5a' : 'var(--text-muted)',
background: urgent ? 'rgba(212,90,90,0.06)' : 'transparent',
}}
>
drop here
{urgent ? '\u26A0 needs shape' : 'drop here'}
</span>
</div>
</div>
Expand Down
9 changes: 6 additions & 3 deletions src/engine/SdfMesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ bool isClipped(vec3 p) {
}
`;

function buildFrag(sdfFunc: string, paramCount: number): string {
function buildFrag(sdfFunc: string, paramCount: number, hasWarn: boolean): string {
return `
precision highp float;
uniform float u_p[${paramCount}];
Expand Down Expand Up @@ -102,6 +102,9 @@ void main() {

vec3 normal = calcNormal(p);
vec3 baseColor = vec3(0.45, 0.56, 0.82);
${hasWarn ? ` float warnDist = abs(sdfWarn(p));
float warnEps = length(u_bbMax - u_bbMin) * 0.002;
if (warnDist < warnEps) baseColor = vec3(0.85, 0.45, 0.25);` : ''}
vec3 viewDir = normalize(vViewDir);
vec3 lightDir = normalize(u_lightDir);

Expand Down Expand Up @@ -181,7 +184,7 @@ export class SdfMesh {
}
}

private rebuild(sdf: { glsl: string; paramCount: number; paramValues: number[]; textures: any[]; bbMin: [number,number,number]; bbMax: [number,number,number] }) {
private rebuild(sdf: { glsl: string; paramCount: number; paramValues: number[]; textures: any[]; bbMin: [number,number,number]; bbMax: [number,number,number]; hasWarn: boolean }) {
if (this.mesh) this.engine.scene.remove(this.mesh);

const initialParams = new Float32Array(sdf.paramCount);
Expand All @@ -206,7 +209,7 @@ export class SdfMesh {

this.material = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: buildFrag(sdf.glsl, sdf.paramCount),
fragmentShader: buildFrag(sdf.glsl, sdf.paramCount, sdf.hasWarn),
uniforms: {
u_p: { value: initialParams },
u_cameraPos: { value: new THREE.Vector3() },
Expand Down
7 changes: 0 additions & 7 deletions src/engine/useEvaluator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect, useRef } from 'react';
import { useModelerStore } from '../store/modelerStore';
import { workerBridge } from './workerBridge';
import { isTreeValid } from '../types/operations';

export function useEvaluator() {
const prevKeyRef = useRef<string>('');
Expand All @@ -20,12 +19,6 @@ export function useEvaluator() {
if (key === prevKeyRef.current) return;
prevKeyRef.current = key;

if (tree && !isTreeValid(tree)) {
useModelerStore.getState().setSDFDisplay(null);
useModelerStore.getState().setEvaluating(false);
return;
}

const seq = ++evalSeqRef.current;
useModelerStore.getState().setEvaluating(true);
useModelerStore.getState().setError(null);
Expand Down
2 changes: 1 addition & 1 deletion src/engine/workerBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class WorkerBridge {
if (!msg.glsl) {
resolve(null);
} else {
resolve({ glsl: msg.glsl, paramCount: msg.paramCount, paramValues: msg.paramValues, textures: msg.textures || [], bbMin: msg.bbMin, bbMax: msg.bbMax });
resolve({ glsl: msg.glsl, paramCount: msg.paramCount, paramValues: msg.paramValues, textures: msg.textures || [], bbMin: msg.bbMin, bbMax: msg.bbMax, hasWarn: !!msg.hasWarn });
}
} else if (msg.type === 'error') reject(new Error(msg.message));
};
Expand Down
13 changes: 7 additions & 6 deletions src/store/modelerStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,17 @@ describe('Modeler editing scenarios', () => {
expect(getState().tree).toBeNull();
});

it('deleting a child of a boolean leaves remaining child', () => {
it('deleting a child of a boolean preserves slot order', () => {
getState().addPrimitive('box');
getState().addPrimitive('sphere');
// tree is union(box, sphere)
const sphereId = getState().tree!.children[1].id;
getState().removeNode(sphereId);
// Union with 1 child should have only the box left
// removeFromTree filters out null children
const boxId = getState().tree!.children[0].id;
getState().removeNode(boxId);
// Slot order preserved: empty placeholder at index 0, sphere at index 1
expect(getState().tree!.kind).toBe('union');
expect(getState().tree!.children).toHaveLength(1);
expect(getState().tree!.children).toHaveLength(2);
expect(getState().tree!.children[0].kind).toBe('_empty');
expect(getState().tree!.children[1].kind).toBe('sphere');
});

it('deleting a modifier promotes its child', () => {
Expand Down
57 changes: 37 additions & 20 deletions src/store/modelerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface SDFDisplayData {
textures: { name: string; width: number; height: number; data: number[] }[];
bbMin: [number, number, number];
bbMax: [number, number, number];
hasWarn: boolean;
}

interface ModelerState {
Expand Down Expand Up @@ -93,18 +94,42 @@ function updateInTree(tree: SDFNodeUI, id: string, updater: (node: SDFNodeUI) =>
};
}

// A placeholder that occupies a boolean slot without producing geometry.
// The tree UI renders it as an empty slot and the SDF converter skips it.
function emptySlot(): SDFNodeUI {
return { id: uuidv4(), kind: '_empty', label: '', params: {}, children: [], enabled: false };
}

function removeFromTree(tree: SDFNodeUI, id: string): SDFNodeUI | null {
if (tree.id === id) {
// If this node has exactly one child, promote the child
if (tree.children.length === 1) return tree.children[0];
return null;
}
return {
...tree,
children: tree.children
.map((child) => removeFromTree(child, id))
.filter((c): c is SDFNodeUI => c !== null),
};

const mapped = tree.children.map((child) => removeFromTree(child, id));

let newChildren: SDFNodeUI[];
if (NODE_KINDS.booleans.includes(tree.kind as any)) {
// For booleans, preserve slot positions: replace removed children with
// disabled placeholder nodes so the remaining operand keeps its index.
newChildren = mapped.map((c) => c ?? emptySlot());
} else {
newChildren = mapped.filter((c): c is SDFNodeUI => c !== null);
}

return { ...tree, children: newChildren };
}

/** Add a child to a node, replacing the first _empty placeholder if one exists. */
function addChildPreferSlot(node: SDFNodeUI, child: SDFNodeUI): SDFNodeUI {
const emptyIdx = node.children.findIndex(c => c.kind === '_empty');
if (emptyIdx >= 0) {
const updated = [...node.children];
updated[emptyIdx] = child;
return { ...node, children: updated };
}
return { ...node, children: [...node.children, child] };
}

function reassignIds(node: SDFNodeUI): SDFNodeUI {
Expand Down Expand Up @@ -413,7 +438,8 @@ export const useModelerStore = create<ModelerState>()((set, get) => ({
if (!targetNode) return;
const targetIsPrim = NODE_KINDS.primitives.includes(targetNode.kind as any);
const targetExpected = expectedChildren(targetNode.kind);
const targetHasRoom = targetNode.children.length < targetExpected;
const targetEmptySlot = targetNode.children.findIndex(c => c.kind === '_empty');
const targetHasRoom = targetNode.children.length < targetExpected || targetEmptySlot >= 0;

if (isOp && targetIsPrim) {
// Operation dropped on a primitive → WRAP the primitive
Expand All @@ -438,10 +464,7 @@ export const useModelerStore = create<ModelerState>()((set, get) => ({
commit(newTree, newNode.id, [unionNode.id]);
} else if (targetHasRoom || targetExpected === 0) {
// Target has room for children, or is a primitive somehow → add as child
const newTree = updateInTree(tree, targetId, (node) => ({
...node,
children: [...node.children, newNode],
}));
const newTree = updateInTree(tree, targetId, (node) => addChildPreferSlot(node, newNode));
commit(newTree, newNode.id, [targetId]);
} else if (isOp) {
// Operation dropped on an operation that's full → wrap the target
Expand All @@ -454,11 +477,8 @@ export const useModelerStore = create<ModelerState>()((set, get) => ({
}
commit(newTree, newNode.id, [newNode.id]);
} else {
// Primitive on a full operation → add as child anyway (user can fix)
const newTree = updateInTree(tree, targetId, (node) => ({
...node,
children: [...node.children, newNode],
}));
// Primitive on a full operation → replace empty slot or add as child
const newTree = updateInTree(tree, targetId, (node) => addChildPreferSlot(node, newNode));
commit(newTree, newNode.id, [targetId]);
}
},
Expand All @@ -482,10 +502,7 @@ export const useModelerStore = create<ModelerState>()((set, get) => ({
if (!treeWithout) return;

// Add source as child of target
const newTree = updateInTree(treeWithout, targetId, (node) => ({
...node,
children: [...node.children, cloneTree(sourceNode)],
}));
const newTree = updateInTree(treeWithout, targetId, (node) => addChildPreferSlot(node, cloneTree(sourceNode)));

const expanded = new Set(get().expandedNodes);
expanded.add(targetId);
Expand Down
2 changes: 1 addition & 1 deletion src/types/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type WorkerRequest =

export type WorkerResponse =
| { type: 'mesh'; positions: ArrayBuffer; normals: ArrayBuffer; indices: ArrayBuffer; thickness?: ArrayBuffer }
| { type: 'sdf'; glsl: string; paramCount: number; paramValues: number[]; textures?: { name: string; width: number; height: number; data: number[] }[]; bbMin: [number, number, number]; bbMax: [number, number, number] }
| { type: 'sdf'; glsl: string; paramCount: number; paramValues: number[]; textures?: { name: string; width: number; height: number; data: number[] }[]; bbMin: [number, number, number]; bbMax: [number, number, number]; hasWarn?: boolean }
| { type: 'exportResult'; format: 'stl' | '3mf'; data: ArrayBuffer }
| { type: 'error'; message: string }
| { type: 'ready' };
Loading
Loading