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
9 changes: 9 additions & 0 deletions changelog/entries/2026-06-04-circuit-tab.json
Original file line number Diff line number Diff line change
@@ -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"]
}
9 changes: 9 additions & 0 deletions changelog/entries/2026-06-04-global-power-label-nets.json
Original file line number Diff line number Diff line change
@@ -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"]
}
9 changes: 9 additions & 0 deletions changelog/entries/2026-06-04-searchable-parts-palette.json
Original file line number Diff line number Diff line change
@@ -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"]
}
9 changes: 9 additions & 0 deletions changelog/entries/2026-06-04-start-blank-resets.json
Original file line number Diff line number Diff line change
@@ -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"]
}
9 changes: 9 additions & 0 deletions changelog/entries/2026-06-04-wire-snap-pin-label.json
Original file line number Diff line number Diff line change
@@ -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"]
}
146 changes: 140 additions & 6 deletions crates/vcad-ecad-schematic/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, usize> = 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<usize, Vec<NetConnection>> = HashMap::new();
Expand All @@ -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());
}

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 7 additions & 7 deletions packages/app/src/components/InlineOnboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,9 +52,7 @@ export function InlineOnboarding({ visible }: InlineOnboardingProps) {
const fileInputRef = useRef<HTMLInputElement>(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,
Expand Down Expand Up @@ -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();
}

Expand Down
66 changes: 50 additions & 16 deletions packages/app/src/components/electronics/ElectronicsToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -413,21 +414,54 @@ export function ElectronicsToolbar() {
</>
);

const renderComponentsContent = () => (
<>
{symbols.map((sym) => (
<ToolbarButton
key={sym.id}
tooltip={`${sym.name} (${sym.defaultValue})`}
active={placingSymbol === sym.id}
onClick={() => placeComponent(sym.id)}
iconColor={ELECTRONICS_TAB_COLORS.components}
>
<SymbolIcon id={sym.id} />
</ToolbarButton>
))}
</>
);
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 (
<div
className="flex w-56 flex-col gap-1 p-1"
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={componentSearch}
onChange={(e) => 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"
/>
<div className="flex max-h-[50vh] flex-col gap-0.5 overflow-y-auto">
{filtered.map((sym) => (
<ToolbarButton
key={sym.id}
tooltip={`${sym.name} (${sym.defaultValue})`}
active={placingSymbol === sym.id}
onClick={() => placeComponent(sym.id)}
iconColor={ELECTRONICS_TAB_COLORS.components}
label={sym.name}
expanded
className="!justify-start gap-2 px-2"
>
<SymbolIcon id={sym.id} />
</ToolbarButton>
))}
{filtered.length === 0 && (
<div className="px-2 py-1 text-[10px] text-text-muted">
No parts match “{componentSearch}”.
</div>
)}
</div>
</div>
);
};

const renderPcbContent = () => (
<>
Expand Down
Loading