Skip to content
Open
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
76 changes: 73 additions & 3 deletions apps/editor/components/build-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
useFloorplanMode,
useTerraceDraft,
useWallDrawVariant,
WALL_DRAW_VARIANTS,
} from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
import { useViewer } from '@pascal-app/viewer'
Expand All @@ -42,11 +43,13 @@ import {
BASE_BUILD_TYPES,
type BuildType,
collectBuildTypes,
collectMepSearchTools,
collectRoofFeatures,
MEP_ITEMS,
MEP_TOOL_KINDS,
type MepItem,
} from '@/lib/build-palette'
import { searchBuildTools } from '@/lib/build-search'
import { getActiveRoofFeatureId, ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state'
import { cn } from '@/lib/utils'
import { KitchenBrowser } from './kitchen-browser'
Expand Down Expand Up @@ -194,6 +197,54 @@ export function BuildTab() {
buildTypes.filter((type) => type.section === section)
const advancedTypes = typesIn('advanced')

const searchResults = searchBuildTools(
[
...buildTypes
.filter((type) => type.id !== 'wall')
.map((type) => ({
...type,
active: isTypeActive(type),
activate: () => handleTypeClick(type),
})),
...WALL_DRAW_VARIANTS.map((variant) => ({
id: variant.id === 'walls' ? 'wall' : `room-${variant.id}`,
label: variant.title,
iconSrc: variant.iconSrc,
active: mode === 'build' && activeTool === 'wall' && wallVariant === variant.id,
activate: () => {
selectWallDrawVariant(variant.id)
const editor = useEditor.getState()
if (!(editor.mode === 'build' && editor.tool === 'wall')) activateBuildTool('wall')
},
})),
...(registryReady ? collectMepSearchTools() : MEP_ITEMS).map((item) => ({
...item,
active: isMepItemActive(item),
activate: () => activateBuildTool(item.kind),
})),
...ROOF_TYPE_OPTIONS.map((roofType) => ({
id: `roof-${roofType.value}`,
label: `${roofType.label} roof`,
iconSrc: '/icons/roof.webp',
active: mode === 'build' && activeTool === 'roof' && activeRoofType === roofType.value,
activate: () => activateRoofType(roofType.value),
})),
...(['gate', 'opening'] as const).map((kind) => ({
id: `fence-${kind}`,
label: kind === 'gate' ? 'Fence gate' : 'Fence opening',
iconSrc: kind === 'gate' ? '/icons/gate.webp' : '/icons/open-passage.webp',
active: placingFenceFeature === kind,
activate: () => activateFenceFeaturePlacement(kind),
})),
...roofFeatures.map((feature) => ({
...feature,
active: mode === 'build' && feature.id === activeRoofFeatureId,
activate: () => activateRoofFeatureTool(feature),
})),
],
buildSearch,
)

return (
<div className="flex h-full flex-col gap-3 overflow-y-auto p-3">
<label className="sr-only" htmlFor="build-search">
Expand All @@ -210,9 +261,24 @@ export function BuildTab() {
{buildSearch.trim() ? (
<>
<BuildToolGrid columns={4}>
{buildTypes
.filter((type) => type.label.toLowerCase().includes(buildSearch.toLowerCase()))
.map(renderTile)}
{searchResults.map((result) => (
<BuildToolTile
key={result.id}
data-build-tool={result.id}
active={result.active}
iconSrc={result.iconSrc}
label={result.label}
title={result.label}
onClick={() => {
triggerSFX('sfx:menu-click')
setBuildSearch('')
setKitchenOpen(false)
setMepOpen(false)
result.activate()
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
/>
))}
</BuildToolGrid>
<KitchenBrowser query={buildSearch} />
</>
Expand Down Expand Up @@ -539,6 +605,10 @@ export function BuildTab() {
</div>
</TooltipProvider>

{(activeTool === 'hvac-equipment' || activeTool === 'duct-terminal') && (
<ToolOptionsPanel kind={activeTool} />
)}

{(['duct-fitting', 'pipe-fitting'] as const)
.filter((kind) => (kind === 'duct-fitting' ? ductContext : pipeContext))
.map((kind) => (
Expand Down
19 changes: 18 additions & 1 deletion apps/editor/lib/build-palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const MEP_ITEMS: MepItem[] = [
kind: 'duct-terminal',
},
{ id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' },
{ id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' },
{ id: 'lineset', label: 'Suction line', iconSrc: '/icons/lineset.webp', kind: 'lineset' },
{ id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' },
{ id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' },
]
Expand All @@ -110,6 +110,23 @@ export const MEP_TOOL_KINDS = new Set<string>([
'pipe-trap',
])

export function collectMepSearchTools(): MepItem[] {
const tools = [...MEP_ITEMS]
for (const kind of MEP_TOOL_KINDS) {
if (tools.some((tool) => tool.kind === kind)) continue
const definition = nodeRegistry.get(kind)
if (!definition?.tool || !definition.presentation) continue
const { label, icon } = definition.presentation
tools.push({
id: kind,
kind,
label,
iconSrc: icon.kind === 'url' ? icon.src : '/icons/HVAC.webp',
})
}
return tools
}

const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet')
export const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp'

Expand Down
49 changes: 49 additions & 0 deletions apps/editor/lib/build-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import { searchBuildTools } from './build-search'

const tools = [
{ id: 'slab', label: 'Slab' },
{ id: 'hvac-equipment', label: 'HVAC Unit' },
{ id: 'room-rectangle', label: 'Rectangle room' },
{ id: 'room-polygon', label: 'Polygon room' },
{ id: 'skylight', label: 'Skylight' },
{ id: 'duct-segment', label: 'Duct' },
{ id: 'terrace', label: 'Terrace' },
]

describe('build tool search', () => {
test('finds room shapes, roof accessories and MEP tools', () => {
for (const [query, id] of [
['rectangle', 'room-rectangle'],
['skylight', 'skylight'],
['light', 'skylight'],
['duct', 'duct-segment'],
]) {
expect(searchBuildTools(tools, query!).map((tool) => tool.id)).toEqual([id!])
}
})

test('matches aliases, whitespace, casing and multiple words', () => {
expect(searchBuildTools(tools, ' FLOOR ')).toEqual([tools[0]!])
expect(searchBuildTools(tools, 'AC')).toEqual([tools[1]!])
expect(searchBuildTools(tools, 'air conditioner')).toEqual([tools[1]!])
expect(searchBuildTools(tools, 'room rect')).toEqual([tools[2]!])
expect(searchBuildTools(tools, 'L-shaped room')).toEqual([tools[3]!])
expect(searchBuildTools(tools, 'unknown tool')).toEqual([])
})

test('deduplicates shared tools and preserves their activation action', () => {
let selected = ''
const canopy = {
id: 'canopy',
label: 'Canopy',
activate: () => {
selected = 'canopy'
},
}
const results = searchBuildTools([canopy, canopy], 'canopy')
expect(results).toHaveLength(1)
results[0]!.activate()
expect(selected).toBe('canopy')
})
})
36 changes: 36 additions & 0 deletions apps/editor/lib/build-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
type SearchableBuildTool = {
id: string
label: string
}

const TOOL_ALIASES: Record<string, readonly string[]> = {
slab: ['floor', 'floor plate', 'platform'],
'hvac-equipment': ['ac', 'air conditioning', 'air conditioner', 'heat pump'],
'duct-terminal': ['air vent', 'register', 'diffuser'],
'pipe-segment': ['plumbing', 'drain', 'waste', 'vent pipe'],
lineset: ['refrigerant line'],
'room-rectangle': ['rectangular room'],
'room-polygon': ['polygonal room', 'l shaped room'],
}

function normalize(value: string): string {
return value.toLowerCase().replace(/[-_]/g, ' ').trim()
}

export function searchBuildTools<T extends SearchableBuildTool>(
tools: readonly T[],
query: string,
): T[] {
const terms = normalize(query).split(/\s+/).filter(Boolean)
const seen = new Set<string>()
return tools.filter((tool) => {
if (seen.has(tool.id)) return false
seen.add(tool.id)
const words = normalize(
[tool.label, tool.id, ...(TOOL_ALIASES[tool.id] ?? [])].join(' '),
).split(/\s+/)
return terms.every((term) =>
words.some((word) => (term.length <= 2 ? word.startsWith(term) : word.includes(term))),
)
})
}
Binary file added apps/editor/public/icons/box-vent/box.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/box-vent/cap.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/box-vent/dome.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/canopy/butterfly.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/canopy/gable.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/canopy/mono.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/chimney/brick.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/chimney/modern.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/chimney/round.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/cupola/dome.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/cupola/pyramid.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/dormer/dutch.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/dormer/flat.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/dormer/gable.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/dormer/gambrel.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/dormer/hip.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/dormer/mansard.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/dormer/shed.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/downspout/kickout.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/downspout/rect.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/downspout/round.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/downspout/splash.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/downspout/straight.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/eyebrow-vent/scoop.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/icons/gutter/box.webp
Binary file added apps/editor/public/icons/gutter/half-round.webp
Binary file added apps/editor/public/icons/gutter/k-style.webp
Binary file added apps/editor/public/icons/ridge-vent/metal.webp
Binary file added apps/editor/public/icons/ridge-vent/shingled.webp
Binary file added apps/editor/public/icons/ridge-vent/standard.webp
Binary file added apps/editor/public/icons/skylight/flat.webp
Binary file added apps/editor/public/icons/skylight/lantern.webp
Binary file added apps/editor/public/icons/skylight/opening.webp
Binary file added apps/editor/public/icons/skylight/sliding.webp
Binary file added apps/editor/public/icons/skylight/walk-on.webp
Binary file added apps/editor/public/icons/solar-panel/compact.webp
Binary file added apps/editor/public/icons/turbine-vent/globe.webp
11 changes: 11 additions & 0 deletions packages/core/src/contracts/reference-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,13 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [

/** Candidate paths that are not references, with the reason. `kind: '*'` matches every kind. */
export const NON_REFERENCES: readonly { kind: string; path: string; reason: string }[] = [
{ kind: 'hvac-equipment', path: 'supplyFace', reason: 'Cabinet side enum, not a node reference' },
{ kind: 'hvac-equipment', path: 'returnFace', reason: 'Cabinet side enum, not a node reference' },
{
kind: 'hvac-equipment',
path: 'serviceFace',
reason: 'Cabinet side enum, not a node reference',
},
{ kind: '*', path: 'id', reason: "The node's own id: a definition, not a reference." },
{
kind: '*',
Expand Down Expand Up @@ -951,6 +958,10 @@ const described = (reason: string, paths: readonly string[]) =>

/** Metadata keys found in editor sources that are not references. */
export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[] = [
...described(
'Architect API request metadata: hosted project identity and credit budget, not scene-node references.',
['project_id', 'pascal_max_credits'],
),
{ path: 'metadata.roomBoundary', reason: 'Boolean opt-out from room boundary detection.' },
...described(
"author_scene's provenance: the payload's own ids on walls and openings, a room's seed, a level's elevation, a slab's finish, a wall's colour, and on the building the stored payload, the report and when it was written; none of it names a node.",
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/registry/plugin-v1-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,18 @@ const pipeDef: NodeDefinition<typeof Pipe> = {
ports: ({ path }) => {
const start = path[0]
const end = path.at(-1)
return start && end && path.length > 1 ? [water('start', start), water('end', end)] : []
return start && end && path.length > 1
? [
{
...water('start', start),
direction: start.map((value, i) => value - path[1]![i]!) as [number, number, number],
},
{
...water('end', end),
direction: end.map((value, i) => value - path.at(-2)![i]!) as [number, number, number],
},
]
: []
},
}
const benchDef: NodeDefinition<typeof Bench> = {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ export type NodePort = {
* face at roll 0, height the vertical one. */
width?: number
height?: number
/** Physical connection subtype, such as suction or liquid refrigerant. */
connectionType?: string
/** Ports sharing a group are one flow path of one system (F6). Unread until F6. */
group?: string
/** Membership label of this port's group; labels, never connects (F6). Unread until F6. */
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/schema/host-children.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,19 @@ test('frozen pre-slice corpus covers every kind predating separators', () => {
test.each(baseline)(
'pre-slice $type parses, saves and loads without changing existing fields',
(saved) => {
const expected = saved.type === 'column' ? { ...saved, children: [] } : saved
const expected =
saved.type === 'column'
? { ...saved, children: [] }
: saved.type === 'hvac-equipment'
? {
...saved,
supplyFace: 'top',
returnFace: 'left',
serviceFace: 'right',
suctionDiameter: 0.875,
liquidDiameter: 0.375,
}
: saved
const parsedExpected =
saved.type === 'zone'
? { ...expected, boundarySeparatorIds: [], holes: [] }
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schema/nodes/box-vent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const BoxVentNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),

// Missing on old scenes: preserve their original unflashed footprint.
flashingWidth: z.number().min(0).optional(),
width: z.number().default(0.4),
depth: z.number().default(0.4),
height: z.number().default(0.15),
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/schema/nodes/hvac-equipment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { BaseNode, nodeType, objectId } from '../base'
export const HvacEquipmentNode = BaseNode.extend({
id: objectId('hvac-equipment'),
type: nodeType('hvac-equipment'),
slots: z.record(z.string(), z.string()).optional(),
// Level-local meters, y at the unit's base.
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Yaw in radians.
Expand All @@ -31,6 +32,11 @@ export const HvacEquipmentNode = BaseNode.extend({
width: z.number().min(0.3).max(2).default(0.56),
depth: z.number().min(0.3).max(2).default(0.71),
height: z.number().min(0.4).max(2.5).default(1.1),
supplyFace: z.enum(['top', 'left', 'right']).default('top'),
returnFace: z.enum(['left', 'right']).default('left'),
serviceFace: z.enum(['left', 'right']).default('right'),
suctionDiameter: z.number().min(0.25).max(2).default(0.875),
liquidDiameter: z.number().min(0.125).max(1).default(0.375),
// Duct collar cross-section on the supply / return connections. Round is
// the default; rect and oval (flat-oval) match the duct shapes a run
// might mate with. Condensers carry no duct collars (ignored).
Expand All @@ -53,6 +59,8 @@ export const HvacEquipmentNode = BaseNode.extend({
- rotation: yaw radians
- equipmentType: furnace | air-handler | condenser
- width / depth / height: cabinet size in meters
- supplyFace: top | left | right; returnFace / serviceFace: left | right
- suctionDiameter / liquidDiameter: separate refrigerant connection sizes in inches
- supplyShape / returnShape: round | rect | oval duct collar cross-section (ignored by condenser)
- supplyDiameter / returnDiameter: round collar sizes in inches
- supplyWidth / supplyHeight / returnWidth / returnHeight: rect / oval collar cross-section in inches
Expand Down
26 changes: 7 additions & 19 deletions packages/core/src/schema/nodes/lineset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,28 @@ import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'

/**
* Refrigerant lineset — the copper pipe pair that links the outdoor
* condenser to the indoor coil (furnace / air handler) of a split system.
* It is the refrigerant-side analogue of a duct run: a polyline of points,
* but carrying two lines instead of one airway.
*
* Real linesets run a fat insulated SUCTION line (cool vapour back to the
* compressor) beside a thin bare LIQUID line (warm liquid out to the coil).
* The geometry builder draws a single copper line on the path centerline
* (sized to `suctionDiameter`, wrapped in a foam jacket when `insulated`);
* draw the liquid line as a second lineset rather than both off one path.
*
* Path coordinates are level-local meters: [x, y, z] tuples, same space as
* duct paths and grid events. Diameters are nominal copper OD in inches.
*/
/** Single refrigerant suction route, retaining the persisted `lineset` kind.
* The liquid route uses `liquid-line`. Path points are level-local metres;
* diameters are nominal copper OD in inches. */
export const LinesetNode = BaseNode.extend({
id: objectId('lineset'),
type: nodeType('lineset'),
slots: z.record(z.string(), z.string()).optional(),
// Polyline path in level-local meters. Minimum two points (start, end).
path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2),
// Nominal suction-line copper OD in inches (the large insulated line).
// Common residential sizes are 3/4"–1-1/8".
suctionDiameter: z.number().min(0.25).max(2).default(0.875),
// Nominal liquid-line copper OD in inches (the small bare line).
// Common residential sizes are 1/4"–3/8".
// Retained when loading/saving old scenes; only liquid-line renders the liquid route.
liquidDiameter: z.number().min(0.125).max(1).default(0.375),
// Whether the suction line carries its foam insulation jacket. Bare = false.
insulated: z.boolean().default(true),
}).describe(
dedent`
Refrigerant lineset - copper suction + liquid pair linking a condenser to an indoor coil.
Refrigerant suction line linking a condenser to an indoor coil. The separate liquid-line node models the liquid route.
- path: list of [x, y, z] points in level-local meters (min 2)
- suctionDiameter: nominal copper OD in inches of the large insulated line (typ. 3/4"-1-1/8")
- liquidDiameter: nominal copper OD in inches of the small bare line (typ. 1/4"-3/8")
- liquidDiameter: retained legacy data; does not change this suction-line geometry
- insulated: whether the suction line wears its foam jacket
`,
)
Expand Down
Loading
Loading