Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
3731eb3
Add roof surface placement support for items
sudhir9297 May 18, 2026
ed53bc2
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
fd8e02c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
7c1e383
fixed conflict
sudhir9297 May 20, 2026
b3377da
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
f177a65
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
9af7491
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
fd27524
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 27, 2026
b516298
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 28, 2026
ebfc8ce
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 3, 2026
b7b313b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
b2ad645
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
bffdb4a
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 8, 2026
ee7b10c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 9, 2026
7d4b474
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 10, 2026
3a3318c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 13, 2026
26df69f
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 17, 2026
5376e07
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 22, 2026
d2204aa
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 23, 2026
f2a5186
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 29, 2026
5841052
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 1, 2026
a6acaa3
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 8, 2026
cb9d7e6
feat(editor): add measurement tool
sudhir9297 Jul 8, 2026
96a4d32
feat(editor): add quick measurement modifier
sudhir9297 Jul 9, 2026
5bc1ded
feat(editor): add measurement axis lock
sudhir9297 Jul 9, 2026
f5951d3
feat(editor): add measurement snap feedback
sudhir9297 Jul 9, 2026
e0f8ece
feat(editor): add measurement helper hints
sudhir9297 Jul 9, 2026
9a51533
Enhance measurement tool snapping
sudhir9297 Jul 9, 2026
c39dd19
feat(editor): enhance 2D and 3D measurement tools with improved label…
sudhir9297 Jul 10, 2026
580aeff
feat(editor): update measurement tool to include precision cycling an…
sudhir9297 Jul 10, 2026
e0fec5b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 10, 2026
fa6fd1d
Enhance measurement tool visuals
sudhir9297 Jul 10, 2026
bed452b
Enhance measurement tool overlays
sudhir9297 Jul 10, 2026
be2b632
Improve measurement tool references
sudhir9297 Jul 11, 2026
e368041
Align measurement angle baselines with registry
sudhir9297 Jul 11, 2026
a5ace1d
Format measurement tool store
sudhir9297 Jul 11, 2026
7fa9276
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 13, 2026
cda81a1
Merge branch 'main' into feat/measurement-tool
sudhir9297 Jul 13, 2026
5d87835
Refine measurement picking and stroke widths
sudhir9297 Jul 13, 2026
913d302
feat(editor): link measurement endpoints while dragging
sudhir9297 Jul 14, 2026
d834118
Add attached measurement scene graph support
sudhir9297 Jul 14, 2026
ab131b0
Refactor measurement scene graph sync
sudhir9297 Jul 14, 2026
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: 2 additions & 0 deletions apps/editor/components/build-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type BuildToolKind =
| 'ceiling'
| 'roof'
| 'stair'
| 'measurement'
| 'elevator'
| 'door'
| 'window'
Expand Down Expand Up @@ -74,6 +75,7 @@ const BUILD_TYPES: BuildType[] = [
{ id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' },
{ id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' },
{ id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' },
{ id: 'measurement', label: 'Measure', iconSrc: '/icons/blueprint.webp', kind: 'measurement' },
{ id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' },
{ id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' },
{ id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' },
Expand Down
12 changes: 12 additions & 0 deletions apps/editor/components/viewer-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
Grid2X2,
Magnet,
PenLine,
Ruler,
SlidersHorizontal,
Sparkles,
SwatchBook,
Expand Down Expand Up @@ -293,6 +294,8 @@ function DisplayMenu() {
const setShadows = useViewer((state) => state.setShadows)
const magneticSnap = useEditor((state) => state.magneticSnap)
const setMagneticSnap = useEditor((state) => state.setMagneticSnap)
const showMeasurements = useEditor((state) => state.showMeasurements)
const setShowMeasurements = useEditor((state) => state.setShowMeasurements)

const activeShading =
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
Expand Down Expand Up @@ -341,6 +344,15 @@ function DisplayMenu() {
{magneticSnap ? 'On' : 'Off'}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))}
>
<Ruler className="h-4 w-4" />
<span>Measurements</span>
<span className="ml-auto text-muted-foreground text-xs">
{showMeasurements ? 'On' : 'Off'}
</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShadows(!shadows))}>
<Contrast className="h-4 w-4" />
<span>Shadows</span>
Expand Down
1 change: 1 addition & 0 deletions apps/editor/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
allowedDevOrigins: ['127.0.0.1'],
logging: {
browserToTerminal: true,
},
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/events/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
LevelNode,
LinesetNode,
LiquidLineNode,
MeasurementNode,
PipeFittingNode,
PipeSegmentNode,
PipeTrapNode,
Expand Down Expand Up @@ -129,6 +130,7 @@ export type PipeFittingEvent = NodeEvent<PipeFittingNode>
export type PipeTrapEvent = NodeEvent<PipeTrapNode>
export type LinesetEvent = NodeEvent<LinesetNode>
export type LiquidLineEvent = NodeEvent<LiquidLineNode>
export type MeasurementEvent = NodeEvent<MeasurementNode>

// Event suffixes - exported for use in hooks
export const eventSuffixes = [
Expand Down Expand Up @@ -308,6 +310,7 @@ type EditorEvents = GridEvents &
NodeEvents<'pipe-trap', PipeTrapEvent> &
NodeEvents<'lineset', LinesetEvent> &
NodeEvents<'liquid-line', LiquidLineEvent> &
NodeEvents<'measurement', MeasurementEvent> &
CameraControlEvents &
ToolEvents &
GuideEvents &
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/hooks/scene-registry/scene-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,18 @@ export const sceneRegistry = {
// Master lookup: ID -> Object3D
nodes: new Map<string, THREE.Object3D>(),

// Reverse ownership lookup used to keep nested R3F events attached to the
// closest registered scene node instead of bubbling into container nodes.
nodeIds: new WeakMap<THREE.Object3D, string>(),

// Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind
// gets a Set on first touch — no hardcoded list.
byType: byTypeProxy,

/** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */
clear() {
this.nodes.clear()
this.nodeIds = new WeakMap<THREE.Object3D, string>()
for (const set of byTypeStore.values()) {
set.clear()
}
Expand All @@ -64,13 +69,15 @@ export function useRegistry(id: string, type: string, ref: React.RefObject<THREE

// 1. Add to master map
sceneRegistry.nodes.set(id, obj)
sceneRegistry.nodeIds.set(obj, id)

// 2. Add to type-specific set — Proxy auto-creates on first access.
sceneRegistry.byType[type]!.add(id)

// 3. Cleanup when component unmounts
return () => {
sceneRegistry.nodes.delete(id)
sceneRegistry.nodeIds.delete(obj)
sceneRegistry.byType[type]!.delete(id)
}
}, [id, type, ref])
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ export type {
LazyComponent,
LiveTransformLike,
McpOverrides,
MeasurementDefinition,
MeasurementDefinitionArea,
MeasurementDefinitionDirectLength,
MeasurementDefinitionPerimeter,
MeasurementDefinitionPoint,
MeasurementDefinitionSnapAnchor,
MeasurementDefinitionSnapGeometry,
MeasurementDefinitionSnapKind,
MeasurementDefinitionSnapSegment,
Modifiers,
MovableConfig,
MovableParentFrame,
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,84 @@ export type SnapProfile = 'item' | 'structural'
*/
export type BakePolicy = 'static' | 'strip' | 'replace'

export type MeasurementDefinitionPoint = readonly [number, number, number]

export type MeasurementDefinitionSnapKind =
| 'center'
| 'edge'
| 'endpoint'
| 'grid'
| 'guide'
| 'intersection'
| 'measurement'
| 'midpoint'
| 'surface'
| 'vertex'

export type MeasurementDefinitionSnapAnchor = {
kind?: MeasurementDefinitionSnapKind
label: string
point: MeasurementDefinitionPoint
priority?: number
targetLine?: {
end: MeasurementDefinitionPoint
start: MeasurementDefinitionPoint
}
}

export type MeasurementDefinitionSnapSegment = {
kind?: MeasurementDefinitionSnapKind
label: string
sourceId?: string
start: MeasurementDefinitionPoint
end: MeasurementDefinitionPoint
priority?: number
}

export type MeasurementDefinitionSnapGeometry = {
anchors?: MeasurementDefinitionSnapAnchor[]
segments?: MeasurementDefinitionSnapSegment[]
}

export type MeasurementDefinitionDirectLength = {
end: MeasurementDefinitionPoint
measuredDistanceMeters: number
start: MeasurementDefinitionPoint
}

export type MeasurementDefinitionArea = {
areaSquareMeters: number
boundaryPoints?: ReadonlyArray<MeasurementDefinitionPoint>
labelPoint: MeasurementDefinitionPoint
}

export type MeasurementDefinitionPerimeter = {
boundaryPoints?: ReadonlyArray<MeasurementDefinitionPoint>
labelPoint: MeasurementDefinitionPoint
lengthMeters: number
}

export type MeasurementOwnerResolveArgs<N> = {
node: N
ctx: GeometryContext
worldPoint: MeasurementDefinitionPoint
}

export type MeasurementDefinition<N> = {
area?: (node: N, ctx: GeometryContext) => MeasurementDefinitionArea | null
applyLiveTransform?: (node: N, live: LiveTransformLike) => AnyNode
directLength?: (
node: N,
ctx: GeometryContext,
cursorPoint?: MeasurementDefinitionPoint | null,
cursorNormal?: MeasurementDefinitionPoint | null,
) => MeasurementDefinitionDirectLength | null
perimeter?: (node: N, ctx: GeometryContext) => MeasurementDefinitionPerimeter | null
pick3D?: boolean
resolveOwnerId?: (args: MeasurementOwnerResolveArgs<N>) => AnyNodeId | null
snapGeometry?: (node: N, ctx: GeometryContext) => MeasurementDefinitionSnapGeometry | null
}

export type ExportAnimationContext<N = AnyNode> = {
node: N
object: Object3D
Expand Down Expand Up @@ -806,6 +884,12 @@ export type NodeDefinition<S extends ZodObject<any>> = {
migrate?: Record<number, (old: unknown) => unknown>

capabilities: Capabilities
/**
* Per-kind geometry for editor measurement tools. This keeps measurement
* snapping, direct length, area, and perimeter behavior registry-driven:
* editor tools call this hook instead of branching on concrete node kinds.
*/
measurement?: MeasurementDefinition<z.infer<S>>
relations?: Relations
parametrics?: ParametricDescriptor<z.infer<S>>

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export {
export { LevelNode } from './nodes/level'
export { LinesetNode } from './nodes/lineset'
export { LiquidLineNode } from './nodes/liquid-line'
export { MeasurementNode, MeasurementPointAttachment } from './nodes/measurement'
export { PipeFittingNode } from './nodes/pipe-fitting'
export { PipeSegmentNode } from './nodes/pipe-segment'
export { PipeTrapNode } from './nodes/pipe-trap'
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/schema/nodes/building.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { LevelNode } from './level'
export const BuildingNode = BaseNode.extend({
id: objectId('building'),
type: nodeType('building'),
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]),
children: z
.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, objectId('measurement')]))
.default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
}).describe(
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/schema/nodes/cabinet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export const CabinetNode = BaseNode.extend({
id: objectId('cabinet'),
type: nodeType('cabinet'),
runTier: z.enum(['base', 'wall', 'tall']).default('base'),
children: z.array(objectId('cabinet-module')).default([]),
children: z.array(z.union([objectId('cabinet-module'), objectId('measurement')])).default([]),
// Raised bar counter along one run edge: a knee wall topped by a slab at
// bar height. Run-level because it spans modules like the countertop.
barLedge: z
Expand All @@ -130,7 +130,9 @@ export const CabinetNode = BaseNode.extend({
export const CabinetModuleNode = BaseNode.extend({
id: objectId('cabinet-module'),
type: nodeType('cabinet-module'),
children: z.array(z.union([objectId('cabinet-module'), objectId('cabinet')])).default([]),
children: z
.array(z.union([objectId('cabinet-module'), objectId('cabinet'), objectId('measurement')]))
.default([]),
cabinetType: z.enum(['base', 'tall']).default('base'),
// Discriminator for specialty units (corner L-shape, sink base, appliance
// gap, open shelving). 'standard' modules use the compartment stack as-is;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/nodes/ceiling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { SurfaceHoleMetadata } from './surface-hole-metadata'
export const CeilingNode = BaseNode.extend({
id: objectId('ceiling'),
type: nodeType('ceiling'),
children: z.array(ItemNode.shape.id).default([]),
children: z.array(z.union([ItemNode.shape.id, objectId('measurement')])).default([]),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
// Per-slot material overrides on the unified slot model, mirroring
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/nodes/item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const ItemNode = BaseNode.extend({
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
side: z.enum(['front', 'back']).optional(),
children: z.array(objectId('item')).default([]),
children: z.array(z.union([objectId('item'), objectId('measurement')])).default([]),

// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
wallId: z.string().optional(),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schema/nodes/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ColumnNode } from './column'
import { FenceNode } from './fence'
import { GuideNode } from './guide'
import { ItemNode } from './item'
import { MeasurementNode } from './measurement'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
import { ShelfNode } from './shelf'
Expand All @@ -25,6 +26,7 @@ export const LevelNode = BaseNode.extend({
FenceNode.shape.id,
ColumnNode.shape.id,
ItemNode.shape.id,
MeasurementNode.shape.id,
ZoneNode.shape.id,
SlabNode.shape.id,
CeilingNode.shape.id,
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/schema/nodes/measurement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'

const MeasurementPoint = z.tuple([z.number(), z.number(), z.number()])

export const MeasurementPointAttachment = z.object({
buildingId: z.string().optional(),
feature: z.discriminatedUnion('kind', [
z.object({ index: z.number().int().nonnegative(), kind: z.literal('plan-anchor') }),
z.object({
index: z.number().int().nonnegative(),
kind: z.literal('plan-segment'),
t: z.number().min(0).max(1),
}),
z.object({ kind: z.literal('node-bounds'), normalized: MeasurementPoint }),
]),
nodeId: z.string(),
ownerNodeId: z.string().optional(),
})

export const MeasurementNode = BaseNode.extend({
id: objectId('measurement'),
type: nodeType('measurement'),
measurementId: z.string(),
start: MeasurementPoint,
end: MeasurementPoint,
view: z.enum(['2d', '3d']),
measuredDistanceMeters: z.number().positive().optional(),
startAttachment: MeasurementPointAttachment.optional(),
endAttachment: MeasurementPointAttachment.optional(),
})

export type MeasurementNode = z.infer<typeof MeasurementNode>
export type MeasurementPointAttachment = z.infer<typeof MeasurementPointAttachment>
2 changes: 1 addition & 1 deletion packages/core/src/schema/nodes/roof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const RoofNode = BaseNode.extend({
// Rotation around Y axis in radians
rotation: z.number().default(0),
// Child roof segment IDs
children: z.array(RoofSegmentNode.shape.id).default([]),
children: z.array(z.union([RoofSegmentNode.shape.id, objectId('measurement')])).default([]),
}).describe(
dedent`
Roof node - a container for roof segments.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/nodes/shelf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const ShelfNode = BaseNode.extend({
// not be rendered, making the commit look like "the item went
// somewhere else"). The action's parent-update branch needs the field
// present at parse-time so the children array is always defined.
children: z.array(ItemNode.shape.id).default([]),
children: z.array(z.union([ItemNode.shape.id, objectId('measurement')])).default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schema/nodes/stair-segment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const StairSegmentNode = BaseNode.extend({
height: z.number().default(2.5),
// Number of steps (only used for stair type)
stepCount: z.number().default(10),
children: z.array(objectId('measurement')).default([]),
// Which side of the previous segment to attach to
attachmentSide: AttachmentSide.default('front'),
// Whether to fill the underside down to floor level
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/nodes/stair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const StairNode = BaseNode.extend({
railingMode: StairRailingMode.default('none'),
railingHeight: z.number().default(0.92),
// Child stair segment IDs
children: z.array(StairSegmentNode.shape.id).default([]),
children: z.array(z.union([StairSegmentNode.shape.id, objectId('measurement')])).default([]),
}).describe(
dedent`
Stair node - a container for stair segments.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/schema/nodes/wall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export const WallNode = BaseNode.extend({
id: objectId('wall'),
type: nodeType('wall'),
children: z
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
.array(
z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id, objectId('measurement')]),
)
.default([]),
// Legacy single-material wall finish. Read for backward compatibility only.
material: MaterialSchema.optional(),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ItemNode } from './nodes/item'
import { LevelNode } from './nodes/level'
import { LinesetNode } from './nodes/lineset'
import { LiquidLineNode } from './nodes/liquid-line'
import { MeasurementNode } from './nodes/measurement'
import { PipeFittingNode } from './nodes/pipe-fitting'
import { PipeSegmentNode } from './nodes/pipe-segment'
import { PipeTrapNode } from './nodes/pipe-trap'
Expand Down Expand Up @@ -83,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [
HvacEquipmentNode,
LinesetNode,
LiquidLineNode,
MeasurementNode,
PipeSegmentNode,
PipeFittingNode,
PipeTrapNode,
Expand Down
Loading
Loading