diff --git a/changelog/entries/2026-06-04-circuit-tab.json b/changelog/entries/2026-06-04-circuit-tab.json new file mode 100644 index 00000000..3ca1c7f4 --- /dev/null +++ b/changelog/entries/2026-06-04-circuit-tab.json @@ -0,0 +1,9 @@ +{ + "id": "2026-06-04-circuit-tab", + "version": "0.9.4", + "date": "2026-06-04", + "category": "feat", + "title": "Circuit tab in the top toolbar", + "summary": "Electronics is now a first-class Circuit tab (between Assembly and Simulate) instead of a PCB button buried under Create, making the schematic/PCB workspace discoverable.", + "features": ["electronics", "pcb"] +} diff --git a/changelog/entries/2026-06-04-global-power-label-nets.json b/changelog/entries/2026-06-04-global-power-label-nets.json new file mode 100644 index 00000000..5d7de3e8 --- /dev/null +++ b/changelog/entries/2026-06-04-global-power-label-nets.json @@ -0,0 +1,9 @@ +{ + "id": "2026-06-04-global-power-label-nets", + "version": "0.9.4", + "date": "2026-06-04", + "category": "feat", + "title": "Global power ports and net labels", + "summary": "Same-named labels and one-pin power symbols (VCC/GND/...) now merge into one net across the sheet without a wire, so power rails no longer need hand-wiring through every pin.", + "features": ["electronics", "pcb"] +} diff --git a/changelog/entries/2026-06-04-searchable-parts-palette.json b/changelog/entries/2026-06-04-searchable-parts-palette.json new file mode 100644 index 00000000..1b775550 --- /dev/null +++ b/changelog/entries/2026-06-04-searchable-parts-palette.json @@ -0,0 +1,9 @@ +{ + "id": "2026-06-04-searchable-parts-palette", + "version": "0.9.4", + "date": "2026-06-04", + "category": "feat", + "title": "Searchable, labeled component palette", + "summary": "The schematic component palette is now a labeled, searchable list (icon + part name, filtered by name/prefix/value) instead of a row of unlabeled icons.", + "features": ["electronics", "pcb"] +} diff --git a/changelog/entries/2026-06-04-start-blank-resets.json b/changelog/entries/2026-06-04-start-blank-resets.json new file mode 100644 index 00000000..2ba289f1 --- /dev/null +++ b/changelog/entries/2026-06-04-start-blank-resets.json @@ -0,0 +1,9 @@ +{ + "id": "2026-06-04-start-blank-resets", + "version": "0.9.4", + "date": "2026-06-04", + "category": "fix", + "title": "\"Start blank\" gives a truly empty document", + "summary": "The welcome screen's Start blank now resets to a fresh empty document instead of dropping a cube onto (and never clearing) whatever was already open.", + "features": ["onboarding"] +} diff --git a/changelog/entries/2026-06-04-wire-snap-pin-label.json b/changelog/entries/2026-06-04-wire-snap-pin-label.json new file mode 100644 index 00000000..27c3d98f --- /dev/null +++ b/changelog/entries/2026-06-04-wire-snap-pin-label.json @@ -0,0 +1,9 @@ +{ + "id": "2026-06-04-wire-snap-pin-label", + "version": "0.9.4", + "date": "2026-06-04", + "category": "feat", + "title": "Wire snap shows the pin name and net", + "summary": "While drawing a wire, the snap target now labels the pin you're about to connect (e.g. Q1.C) and the net it's already on, so you don't accidentally grab the wrong pin.", + "features": ["electronics", "pcb"] +} diff --git a/crates/vcad-ecad-schematic/src/lib.rs b/crates/vcad-ecad-schematic/src/lib.rs index 5c5cd851..b8077ae7 100644 --- a/crates/vcad-ecad-schematic/src/lib.rs +++ b/crates/vcad-ecad-schematic/src/lib.rs @@ -277,6 +277,43 @@ pub fn generate_netlist(sheet: &SchematicSheet) -> Netlist { } } + // Global nets: merge points that share a net name. A name comes from a + // label, or from a power port — a one-pin power symbol like VCC/GND whose + // `value` names a global rail. This makes same-named labels and every + // VCC/GND symbol join a single net across the whole sheet without an + // explicit wire (the standard "global label / power port" behaviour), which + // is what keeps power rails sane on a dense board. + let mut named_anchors: Vec<(String, usize)> = label_indices.clone(); + for comp in &sheet.components { + let is_power_port = comp.pins.len() == 1 + && matches!( + comp.pins[0].pin_type, + PinType::PowerInput | PinType::PowerOutput + ); + if !is_power_port { + continue; + } + let name = comp.value.trim(); + if name.is_empty() { + continue; + } + if let Some(&(_, _, idx)) = pin_indices + .iter() + .find(|(r, p, _)| r == &comp.reference && p == &comp.pins[0].number) + { + named_anchors.push((name.to_string(), idx)); + } + } + let mut first_by_name: HashMap = HashMap::new(); + for (name, idx) in &named_anchors { + match first_by_name.get(name) { + Some(&first) => uf.union(first, *idx), + None => { + first_by_name.insert(name.clone(), *idx); + } + } + } + // Phase 3: Group into nets. // Map root -> set of pin connections and labels. let mut net_pins: HashMap> = HashMap::new(); @@ -290,8 +327,8 @@ pub fn generate_netlist(sheet: &SchematicSheet) -> Netlist { }); } - for &(ref name, idx) in &label_indices { - let root = uf.find(idx); + for (name, idx) in &named_anchors { + let root = uf.find(*idx); net_labels.entry(root).or_default().insert(name.clone()); } @@ -498,15 +535,112 @@ mod tests { }) .expect("R1 pin 1 net"); assert!( - !r1_net - .connections - .iter() - .any(|c| c.component_ref == "R2"), + !r1_net.connections.iter().any(|c| c.component_ref == "R2"), "crossing wires must not connect; R1 net = {:?}", r1_net.connections, ); } + /// A one-pin power port (e.g. VCC/GND) at `position`, pin at the origin. + fn make_power( + reference: &str, + value: &str, + position: Vec2, + pin_type: PinType, + ) -> SchematicComponent { + SchematicComponent { + reference: reference.to_string(), + value: value.to_string(), + footprint_id: String::new(), + position, + rotation: 0.0, + mirror: false, + pins: vec![SchematicPin { + number: "1".to_string(), + name: "1".to_string(), + pin_type, + position: Vec2::new(0.0, 0.0), + }], + properties: std::collections::HashMap::new(), + } + } + + #[test] + fn power_ports_merge_by_value() { + // Two separate VCC symbols (no wire between them) tie their attached + // pins into one global "VCC" net. + let sheet = SchematicSheet { + title: None, + components: vec![ + make_resistor("R1", Vec2::new(10.0, 0.0)), // pins (5,0),(15,0) + make_resistor("R2", Vec2::new(40.0, 0.0)), // pins (35,0),(45,0) + make_power("PWR1", "VCC", Vec2::new(5.0, 0.0), PinType::PowerOutput), // on R1 pin1 + make_power("PWR2", "VCC", Vec2::new(35.0, 0.0), PinType::PowerOutput), // on R2 pin1 + ], + wires: vec![], + junctions: vec![], + labels: vec![], + }; + + let netlist = generate_netlist(&sheet); + let vcc = netlist + .nets + .iter() + .find(|n| n.name == "VCC") + .expect("a net named VCC"); + let has = |r: &str, p: &str| { + vcc.connections + .iter() + .any(|c| c.component_ref == r && c.pin_number == p) + }; + assert!( + has("R1", "1") && has("R2", "1"), + "both VCC ports should merge their pins into one net, got {:?}", + vcc.connections, + ); + } + + #[test] + fn same_name_labels_merge() { + // Two "BUS" labels on unconnected pins join one net by name. + let sheet = SchematicSheet { + title: None, + components: vec![ + make_resistor("R1", Vec2::new(10.0, 0.0)), + make_resistor("R2", Vec2::new(40.0, 0.0)), + ], + wires: vec![], + junctions: vec![], + labels: vec![ + SchematicLabel { + name: "BUS".to_string(), + position: Vec2::new(5.0, 0.0), // R1 pin 1 + rotation: 0.0, + scope: LabelScope::Local, + }, + SchematicLabel { + name: "BUS".to_string(), + position: Vec2::new(35.0, 0.0), // R2 pin 1 + rotation: 0.0, + scope: LabelScope::Local, + }, + ], + }; + + let netlist = generate_netlist(&sheet); + let bus = netlist + .nets + .iter() + .find(|n| n.name == "BUS") + .expect("a net named BUS"); + assert_eq!( + bus.connections.len(), + 2, + "same-named labels should merge, got {:?}", + bus.connections, + ); + } + #[test] fn label_assigns_net_name() { // R1 at (10, 0): pin 1 at world (5,0), pin 2 at world (15,0) diff --git a/packages/app/src/components/InlineOnboarding.tsx b/packages/app/src/components/InlineOnboarding.tsx index a25b3300..bed1b1b3 100644 --- a/packages/app/src/components/InlineOnboarding.tsx +++ b/packages/app/src/components/InlineOnboarding.tsx @@ -15,7 +15,8 @@ import { Waveform } from "@phosphor-icons/react/dist/ssr/Waveform"; import { Star } from "@phosphor-icons/react/dist/ssr/Star"; import type { Icon } from "@phosphor-icons/react"; import { cn } from "@/lib/utils"; -import { useDocumentStore, useUiStore, useChatStore, parseVcadFile } from "@vcad/core"; +import { useDocumentStore, useChatStore, parseVcadFile } from "@vcad/core"; +import { newDocId } from "@/lib/doc-id"; import { useAuth, isAuthEnabled, AuthModal } from "@vcad/auth"; import { useOnboardingStore } from "@/stores/onboarding-store"; import { examples, exampleToVcadFile } from "@/data/examples"; @@ -51,9 +52,7 @@ export function InlineOnboarding({ visible }: InlineOnboardingProps) { const fileInputRef = useRef(null); const loadDocument = useDocumentStore((s) => s.loadDocument); - const addPrimitive = useDocumentStore((s) => s.addPrimitive); - const select = useUiStore((s) => s.select); - const setTransformMode = useUiStore((s) => s.setTransformMode); + const newDocument = useDocumentStore((s) => s.newDocument); const setChatOpen = useChatStore((s) => s.setOpen); const incrementProjectsCreated = useOnboardingStore( (s) => s.incrementProjectsCreated, @@ -96,9 +95,10 @@ export function InlineOnboarding({ visible }: InlineOnboardingProps) { function handleStartBlank() { incrementProjectsCreated(); - const partId = addPrimitive("cube"); - select(partId); - setTransformMode("translate"); + // A blank canvas should actually be blank: reset to a fresh empty + // document (clearing any autosaved one) rather than dropping a cube onto + // whatever happened to be open. + newDocument(newDocId(), "Untitled"); hide(); } diff --git a/packages/app/src/components/electronics/ElectronicsToolbar.tsx b/packages/app/src/components/electronics/ElectronicsToolbar.tsx index c783e287..5475ce86 100644 --- a/packages/app/src/components/electronics/ElectronicsToolbar.tsx +++ b/packages/app/src/components/electronics/ElectronicsToolbar.tsx @@ -6,7 +6,7 @@ * Tabs: Schematic | Components | PCB | View | Finish */ -import { useEffect, useCallback } from "react"; +import { useEffect, useCallback, useState } from "react"; import { Cursor } from "@phosphor-icons/react/dist/ssr/Cursor"; import { ArrowsOutCardinal } from "@phosphor-icons/react/dist/ssr/ArrowsOutCardinal"; import { Plugs } from "@phosphor-icons/react/dist/ssr/Plugs"; @@ -173,6 +173,7 @@ export function ElectronicsToolbar() { const exit = useElectronicsStore((s) => s.exit); const toggleLayout = useElectronicsStore((s) => s.toggleLayout); const symbols = useSymbolLibrary(); + const [componentSearch, setComponentSearch] = useState(""); const unplacedComponents = useElectronicsStore((s) => s.unplacedComponents); const syncSchematicToPcb = useDocumentStore((s) => s.syncSchematicToPcb); @@ -413,21 +414,54 @@ export function ElectronicsToolbar() { ); - const renderComponentsContent = () => ( - <> - {symbols.map((sym) => ( - placeComponent(sym.id)} - iconColor={ELECTRONICS_TAB_COLORS.components} - > - - - ))} - - ); + const renderComponentsContent = () => { + const q = componentSearch.trim().toLowerCase(); + const filtered = q + ? symbols.filter( + (s) => + s.name.toLowerCase().includes(q) || + s.id.toLowerCase().includes(q) || + s.prefix.toLowerCase().includes(q) || + s.defaultValue.toLowerCase().includes(q), + ) + : symbols; + return ( +
e.stopPropagation()} + > + setComponentSearch(e.target.value)} + placeholder="Search parts…" + aria-label="Search parts" + className="w-full rounded border border-border bg-transparent px-2 py-1 text-[11px] text-text placeholder:text-text-muted" + /> +
+ {filtered.map((sym) => ( + placeComponent(sym.id)} + iconColor={ELECTRONICS_TAB_COLORS.components} + label={sym.name} + expanded + className="!justify-start gap-2 px-2" + > + + + ))} + {filtered.length === 0 && ( +
+ No parts match “{componentSearch}”. +
+ )} +
+
+ ); + }; const renderPcbContent = () => ( <> diff --git a/packages/app/src/components/electronics/SchematicCanvas.tsx b/packages/app/src/components/electronics/SchematicCanvas.tsx index 2b11d57b..2b1618d2 100644 --- a/packages/app/src/components/electronics/SchematicCanvas.tsx +++ b/packages/app/src/components/electronics/SchematicCanvas.tsx @@ -46,14 +46,23 @@ function snapToGrid(v: number, grid: number): number { } /** Snap to nearest component pin if within threshold, otherwise grid-snap. */ +interface SnapResult { + x: number; + y: number; + isPin: boolean; + /** When `isPin`, the component ref and pin number snapped to. */ + ref?: string; + pin?: string; +} + function snapToGridOrPin( pos: { x: number; y: number }, components: SchematicComponent[], grid: number, threshold: number = 12, -): { x: number; y: number; isPin: boolean } { +): SnapResult { let bestDist = threshold; - let bestPos = { x: snapToGrid(pos.x, grid), y: snapToGrid(pos.y, grid), isPin: false }; + let bestPos: SnapResult = { x: snapToGrid(pos.x, grid), y: snapToGrid(pos.y, grid), isPin: false }; for (const comp of components) { for (const pin of comp.pins) { const px = comp.position.x + pin.position.x; @@ -61,7 +70,7 @@ function snapToGridOrPin( const d = Math.hypot(pos.x - px, pos.y - py); if (d < bestDist) { bestDist = d; - bestPos = { x: px, y: py, isPin: true }; + bestPos = { x: px, y: py, isPin: true, ref: comp.ref, pin: pin.number }; } } } @@ -251,7 +260,7 @@ export function SchematicCanvas() { const [ghostPos, setGhostPos] = useState<{ x: number; y: number } | null>(null); const [moveDrag, setMoveDrag] = useState<{ compIdx: number; offset: { x: number; y: number } } | null>(null); const [moveConnections, setMoveConnections] = useState<{ wireIdx: number; endpoint: "start" | "end"; pinOffset: { x: number; y: number } }[]>([]); - const [snapTarget, setSnapTarget] = useState<{ x: number; y: number; isPin: boolean } | null>(null); + const [snapTarget, setSnapTarget] = useState(null); const dragStart = useRef<{ x: number; y: number } | null>(null); // Active net from selection @@ -408,7 +417,7 @@ export function SchematicCanvas() { // Update snap target indicator for wire mode if (schTool === "wire") { - setSnapTarget(snapped as { x: number; y: number; isPin: boolean }); + setSnapTarget(snapped as SnapResult); } else { setSnapTarget(null); } @@ -1024,12 +1033,32 @@ export function SchematicCanvas() { {/* Snap target indicator */} {schTool === "wire" && snapTarget && ( - {snapTarget.isPin ? ( - <> - - - - ) : ( + {snapTarget.isPin ? (() => { + const net = snapTarget.ref + ? getNetForPin(snapTarget.ref, snapTarget.pin ?? "", netlist) + : undefined; + return ( + <> + + + {snapTarget.ref && ( + + {snapTarget.ref}.{snapTarget.pin} + {net ? ` · ${net}` : ""} + + )} + + ); + })() : ( )} diff --git a/packages/app/src/components/ui/toolbar-constants.ts b/packages/app/src/components/ui/toolbar-constants.ts index 1617badc..c7fe432d 100644 --- a/packages/app/src/components/ui/toolbar-constants.ts +++ b/packages/app/src/components/ui/toolbar-constants.ts @@ -13,6 +13,7 @@ export const TAB_COLORS: Record = { simulate: "text-cyan-400", build: "text-slate-400", sketch: "text-amber-400", + circuit: "text-indigo-400", }; // Per-tab style theme. All class names are spelled out as literal strings so @@ -88,11 +89,18 @@ export const TAB_THEMES: Record = { hoverBg: "hover:bg-amber-400/5", accent: "bg-amber-400", }, + circuit: { + text: "text-indigo-400", + groupHoverText: "group-hover:text-indigo-400", + bg: "bg-indigo-400/10", + hoverBg: "hover:bg-indigo-400/5", + accent: "bg-indigo-400", + }, }; // One-line descriptions surfaced inside the rich tab tooltips. export const TAB_DESCRIPTIONS: Record = { - create: "Add primitives, sketches, text, and PCB boards.", + create: "Add primitives, sketches, and text.", sketch: "Draw 2D profiles and convert to 3D with extrude, revolve, sweep, or loft.", transform: "Move, rotate, and scale the selected parts.", combine: "Boolean union, difference, and intersection of two parts.", @@ -100,6 +108,7 @@ export const TAB_DESCRIPTIONS: Record = { assembly: "Define parts, place instances, and connect them with joints.", simulate: "Run physics on jointed assemblies — play, pause, step.", build: "Switch views, export STL/GLB/STEP, print, route, and quote.", + circuit: "Design circuits: add a PCB, draw the schematic, place and route the board.", }; // Electronics toolbar tab types diff --git a/packages/app/src/hooks/useToolDefinitions.tsx b/packages/app/src/hooks/useToolDefinitions.tsx index 7d98c9e4..f25ff92f 100644 --- a/packages/app/src/hooks/useToolDefinitions.tsx +++ b/packages/app/src/hooks/useToolDefinitions.tsx @@ -112,6 +112,7 @@ export function getAllTabs(): ToolTabMeta[] { { id: "combine", label: t("toolbar.tab.combine"), icon: Unite }, { id: "modify", label: t("toolbar.tab.modify"), icon: Circle }, { id: "assembly", label: t("toolbar.tab.assembly"), icon: Package }, + { id: "circuit", label: "Circuit", icon: Circuitry }, { id: "simulate", label: t("toolbar.tab.simulate"), icon: Play }, { id: "build", label: t("toolbar.tab.export"), icon: Export }, ]; @@ -343,14 +344,17 @@ export function useToolDefinitions(): { iconColor: color("create"), onClick: () => dispatch("vcad:open-text-dialog"), }, + ]; + + const circuit: ToolDef[] = [ { - id: "create-pcb", - tab: "create", - label: "PCB", - tooltip: "Add PCB Board", + id: "circuit-pcb", + tab: "circuit", + label: "PCB Board", + tooltip: "Add a PCB board and open the circuit editor", icon: Circuitry, enabled: !sketchActive, - iconColor: color("create"), + iconColor: color("circuit"), onClick: () => dispatch("vcad:open-pcb-dialog"), }, ]; @@ -993,6 +997,7 @@ export function useToolDefinitions(): { simulate, build, sketch, + circuit, }; }, [ addPrimitive, diff --git a/packages/core/src/stores/ui-store.ts b/packages/core/src/stores/ui-store.ts index c91a705f..6c498500 100644 --- a/packages/core/src/stores/ui-store.ts +++ b/packages/core/src/stores/ui-store.ts @@ -31,7 +31,7 @@ export type RenderMode = "standard" | "raytrace"; export type RaytraceQuality = "draft" | "standard" | "high"; export type RaytraceDebugMode = "off" | "normals" | "face-id" | "lighting" | "orientation"; -export type ToolbarTab = "create" | "transform" | "combine" | "modify" | "assembly" | "simulate" | "build" | "sketch"; +export type ToolbarTab = "create" | "transform" | "combine" | "modify" | "assembly" | "simulate" | "build" | "sketch" | "circuit"; export type SidebarPane = "tree" | "inspector" | "parameters"; diff --git a/packages/kernel-wasm/vcad_kernel_wasm_bg.wasm b/packages/kernel-wasm/vcad_kernel_wasm_bg.wasm index f62d4fce..b74217ff 100644 Binary files a/packages/kernel-wasm/vcad_kernel_wasm_bg.wasm and b/packages/kernel-wasm/vcad_kernel_wasm_bg.wasm differ